From d7b1fd23d704180bf824fdfcffa153efc98de47a Mon Sep 17 00:00:00 2001 From: David Middleton Date: Wed, 25 Mar 2026 11:19:19 +0000 Subject: [PATCH 1/3] TGC-1119: Integrate config broker s3 --- .env.example | 10 + compose.yml | 13 +- .../grants-ui/example-grant-with-auth.yaml | 585 ++++++++++++++++++ localstack/start-localstack.sh | 10 +- package-lock.json | 74 +-- package.json | 6 +- scripts/upload-form-config.js | 69 +++ .../repositories/form-metadata-repository.js | 7 +- src/api/forms/service/definition.js | 2 - src/api/forms/service/s3-seeder.js | 223 +++++++ src/api/server.js | 2 + src/config/index.js | 36 ++ 12 files changed, 985 insertions(+), 52 deletions(-) create mode 100644 localstack/forms/example-grant-with-auth/0.0.1/grants-ui/example-grant-with-auth.yaml create mode 100644 scripts/upload-form-config.js create mode 100644 src/api/forms/service/s3-seeder.js diff --git a/.env.example b/.env.example index 900e330e..7c72a10b 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,11 @@ JWT_SECRET= # generate-a-random-256-bit-key + +# S3 form seeding on startup +FORMS_CONFIG_BUCKET_NAME= # S3 bucket holding YAML form definitions (e.g. forms-config) +FORMS_API_SLUGS= # comma-separated slugs to load, e.g. example-grant-with-auth,farm-payments + +# Default form metadata values (used when not present in the YAML form definition metadata) +DEFAULT_FORM_ORGANISATION=Defra +DEFAULT_FORM_TEAM_NAME=Digital Delivery +DEFAULT_FORM_TEAM_EMAIL=digitaldelivery@defra.gov.uk +DEFAULT_FORM_NOTIFICATION_EMAIL=digitaldelivery@defra.gov.uk diff --git a/compose.yml b/compose.yml index fa7f4874..656aedd6 100644 --- a/compose.yml +++ b/compose.yml @@ -2,18 +2,19 @@ services: localstack: image: localstack/localstack:3.0.2 ports: - - '4566:4566' # LocalStack Gateway - - '4510-4559:4510-4559' # external services port range + - '4566:4566' + - '4510-4559:4510-4559' env_file: - 'localstack/aws.env' environment: DEBUG: ${DEBUG:-1} - LS_LOG: WARN # Localstack DEBUG Level + LS_LOG: WARN SERVICES: s3,sqs,sns,firehose LOCALSTACK_HOST: 127.0.0.1 volumes: - '${TMPDIR:-/tmp}/localstack:/var/lib/localstack' - ./localstack/start-localstack.sh:/etc/localstack/init/ready.d/start-localstack.sh + - ./localstack/forms:/etc/localstack/forms:ro healthcheck: test: ['CMD', 'curl', 'localhost:4566'] interval: 5s @@ -85,6 +86,12 @@ services: LOCALSTACK_ENDPOINT: http://localstack:4566 MONGO_URI: ${MONGO_URI:-mongodb://mongodb:27017/} JWT_SECRET: ${JWT_SECRET:-config-api-jwt-secret} + FORMS_CONFIG_BUCKET_NAME: ${FORMS_CONFIG_BUCKET_NAME:-forms-config} + FORMS_API_SLUGS: ${FORMS_API_SLUGS:-example-grant-with-auth} + DEFAULT_FORM_ORGANISATION: ${DEFAULT_FORM_ORGANISATION:-Defra} + DEFAULT_FORM_TEAM_NAME: ${DEFAULT_FORM_TEAM_NAME:-Digital Delivery} + DEFAULT_FORM_TEAM_EMAIL: '${DEFAULT_FORM_TEAM_EMAIL:-digitaldelivery@defra.gov.uk}' + DEFAULT_FORM_NOTIFICATION_EMAIL: '${DEFAULT_FORM_NOTIFICATION_EMAIL:-digitaldelivery@defra.gov.uk}' networks: - grants-ui-config-api-net volumes: diff --git a/localstack/forms/example-grant-with-auth/0.0.1/grants-ui/example-grant-with-auth.yaml b/localstack/forms/example-grant-with-auth/0.0.1/grants-ui/example-grant-with-auth.yaml new file mode 100644 index 00000000..3a47973f --- /dev/null +++ b/localstack/forms/example-grant-with-auth/0.0.1/grants-ui/example-grant-with-auth.yaml @@ -0,0 +1,585 @@ +engine: V2 +name: Example grant with auth +metadata: + id: 9f9c5ec4-e237-4e6e-b67c-f232a6377d76 + enabledInProd: false + referenceNumberPrefix: EGWA + cookieConsent: + enabled: true + serviceName: Farm and land service + cookiePolicyUrl: /cookies + expiryDays: 365 + submission: + submissionSchemaPath: ./schemas/example-grant-with-auth-submission.schema.json + grantRedirectRules: null + printPage: + showApplicantDetails: true + configurablePrintContent: + html: | +

Configurable content

+

This is an example of configurable content on the print page, defined via the configurablePrintContent property in the form YAML metadata.

+

It supports HTML markup and the {{SLUG}} placeholder.

+ confirmationContent: + panelTitle: 'Details submitted' + panelText: 'Your reference number' + html: | +

What happens next

+

Defra will email you when your funding offer is available to review. This will be within 5 working days.

+

You will need to sign in to the service to accept your funding offer.

+

You should not start work on the actions you have applied for until:

+ + +

View / Print submitted application (opens in new tab)

+ +
+ + + If you have a question + + +
+

Contact the Rural Payments Agency if you have a query.

+

+ Telephone: 03000 200 301
+ Monday to Friday, 8:30am to 5pm (except bank holidays)

+

Find out about call charges (opens in new tab)

+

Email: ruralpayments@defra.gov.uk

+
+
+pages: + - title: Example Grant + path: /start + controller: StartPageController + components: + - name: startInfoOne + title: Html + type: Html + content:

This service uses DefraID and the Save and Return + feature, and demonstrates the use of the following components and page + types:

+ id: 434ab3dc-fad9-44c5-8601-b19477a1d77e + options: {} + schema: {} + - name: startComponentsDetail + title: Components + type: Details + content: |+ + + + id: dc4b8bac-ac3d-43ba-ae28-575997f8836d + options: {} + schema: {} + - name: startPagesDetails + title: Page types + type: Details + content: |+ + + + id: 884fd5d0-12da-4156-8396-348cce116c96 + options: {} + schema: {} + - name: startInsetText + title: Inset text + type: InsetText + content: | + This page demonstrates the use of guidance components: +
+ Details, InsetText, Html and Markdown. + id: 4e5014d2-7f6a-4e9f-9fa4-01fd5978db4e + options: {} + schema: {} + - name: startMarkdown + title: Markdown + type: Markdown + content: | + Pages can use Markdown like headers and text formatting + e.g. *italic*, **bold** and ~~strikethrough~~ + id: 5bc4496e-c806-478e-9287-907fa317bff2 + options: {} + schema: {} + id: 89f408cd-6953-4f6b-b31c-f957c520fb67 + - title: YesNoField Example + path: /yes-no-field + components: + - name: yesNoField + title: Yes or No + hint: Selecting 'No' will demonstrate navigating to a Terminal page to end the + application + type: YesNoField + options: + customValidationMessages: + any.required: Select 'Yes' to continue + shortDescription: Yes or No + id: 33b2a118-efb3-45b3-a926-22fed2360bf7 + schema: {} + id: d02c043c-6ca2-4d9d-a4a8-b299e5735f7d + - title: Terminal Page Example + path: /terminal-page + controller: TerminalPageController + components: + - name: cannotApplyInfo + title: Html + type: Html + content:

This is an example of a Terminal page to give + details on what conditions have not been met to continue applying for + the grant.

+ id: 19aa43e5-3d6c-45fc-a7f8-8c7d0b43ea0e + options: {} + schema: {} + condition: 048e4fdd-6c54-4a9d-9bc5-c9d90470b07b + id: 94e31c5a-b58e-45bb-b4e0-5960f442dd65 + - title: AutocompleteField Example + path: /autocomplete-field + components: + - name: listComponentInfo + title: Html + type: Html + content:

This is an example of a List guidance component + and an AutocompleteField.

+ id: b0e40157-875a-4fda-97b4-9809cd4f2f28 + options: {} + schema: {} + - name: supportedCountries + title: Countries + type: List + list: bf5432a9-a4b1-4e01-b4c8-3052ad4be056 + id: f1831087-fc60-4384-8ffb-b82d7522ab01 + options: {} + schema: {} + - name: autocompleteField + title: Country + type: AutocompleteField + list: bf5432a9-a4b1-4e01-b4c8-3052ad4be056 + hint: Start typing to see a filtered list of countries to select from + options: + customValidationMessages: + any.required: Enter a country + shortDescription: Country + id: 26958e3c-d6d7-4bec-833b-7661e37a62e7 + schema: {} + id: 728cd1be-ed3b-453c-aaf6-acacfc57742f + - title: RadiosField Example + path: /radios-field + components: + - name: radiosInfo + title: Html + type: Html + content:

This is an example of a RadiosField component. + Options can be configured to go to Terminal pages or Conditional + pages.

+ id: 545895da-dc16-4bd6-8d6a-4308277982df + options: {} + schema: {} + - name: radiosField + title: Radio option + type: RadiosField + list: 57f1e7a6-d27a-4144-ace6-6ddacad103df + hint: Selecting the first option demonstrates navigating to an optional page + before continuing + options: + customValidationMessages: + any.required: Select the option that applies + shortDescription: Radio option + id: b48a816e-a810-4346-a111-5c7a0cac8a5b + schema: {} + id: 5cd9ac2a-d211-4f8e-b1b9-1a377d121148 + - title: Conditional Page Example + path: /conditional-page + components: + - name: conditionalPageInfo + title: Html + type: Html + content: '

This is an example of a Conditional Page. You + must meet certain conditions before you submit a full application.

+ ' + id: c62adf1e-8175-4226-a614-2f889a254036 + options: {} + schema: {} + condition: 7fd31ec4-1e35-4ed8-9f81-c41928f4c8a7 + id: ec8b2f9b-efcc-4019-b4ff-f64675becf4c + - title: CheckboxesField Example + path: /checkboxes-field + components: + - name: checkboxesField + title: Checkbox options + hint: >- + Use checkboxes to allow selection of one or more options. + + This example requires at least one selection, but the component can be + set to optional. + type: CheckboxesField + list: aae88d9a-2b7b-4a49-a7f4-1482d86e6be6 + options: + required: true + shortDescription: Checkbox options + id: b9c60a2f-2c1a-43fd-96f0-7ea6a566ca9a + schema: {} + id: 738a8007-fc6e-4a23-b284-d68be8bf02ee + - title: NumberField Example + path: /number-field + components: + - name: projectCostInfo + type: Html + title: Html + content:

Enter cost of items, for example 695000

+ id: dc6894f1-b7b1-4873-8adb-7ac869da21f7 + options: {} + schema: {} + - name: numberField + type: NumberField + title: Enter amount + options: + prefix: £ + required: true + autocomplete: off + classes: govuk-!-width-one-third + customValidationMessages: + any.required: Enter the numeric value + number.max: Enter a value less than £1 million + number.min: Enter a value of £10000 or more + schema: + min: 10000 + max: 999999 + precision: 0 + shortDescription: Enter amount + id: 4643daa8-d84a-4a1b-942c-3e5f8b53a5ad + id: 57ee940c-a2ab-4c98-8733-887fb5c0d396 + - title: DatePartsField Example + path: /date-parts-field + components: + - name: datePartsField + title: Date + hint: Cannot be in the past and must be within the next 90 days. + type: DatePartsField + options: + autocomplete: off + maxDaysInPast: 1 + maxDaysInFuture: 90 + required: true + shortDescription: Date + id: 70367a37-e152-4db3-94f8-8b1249481234 + schema: {} + id: ae8788f2-a495-4e95-9ae0-34e7ee177788 + - title: MonthYearField Example + path: /month-year-field + components: + - name: monthYearField + title: Month and year + hint: Allows entry of dates with just a month and year. + type: MonthYearField + options: + required: true + shortDescription: Month and year + id: d357b94e-ca25-4ea1-8b01-daa4851a8767 + schema: {} + id: 16d61e73-68bb-465a-a9c3-fa05c41de16c + - title: SelectField Example + path: /select-field + components: + - name: selectFieldInfo + title: Html + type: Html + content:

This is an example of a SelectField.

+ id: 6ea49c90-ae00-426a-8491-4586b9e6a5f1 + options: {} + schema: {} + - name: selectField + title: Select option + type: SelectField + list: d43f6210-34f7-4619-a6cc-30fd579ae85f + options: + required: true + shortDescription: Select option + id: e21204ba-abb5-4b1d-9d84-c7750d877af2 + schema: {} + id: ee00619a-ee21-45cf-a28f-05d28098a103 + - title: MultilineTextField Example + path: /multiline-text-field + components: + - name: multilineTextField + title: Description + type: MultilineTextField + options: + required: true + rows: 10 + maxWords: 400 + customValidationMessages: + string.empty: Enter a description + shortDescription: Description + id: 264fbff1-35f6-49d3-98ae-b7582b1e41b5 + schema: {} + id: 88d7b067-68c2-4fe1-9a9a-ea98f0752e03 + - title: Multi Field Form Example + path: /multi-field-form + components: + - name: formInfo + title: Html + type: Html + content: >- +

This is an example of a form containing multiple + components:
+ + TextField, EmailAddressField, TelephoneNumberField and + UkAddressField.

+ +

UkAddressField is a component that renders + multiple fields, some of which are optional.

+ id: 96f851a6-8758-42dd-9df5-e85e89b8cc14 + options: {} + schema: {} + - type: TextField + name: applicantName + title: Name + options: + required: true + customValidationMessages: + string.empty: Enter your name + string.max: Name must be 30 characters or fewer + string.pattern.base: Name must only include letters, hyphens and apostrophes + schema: + regex: ^[a-zA-Z' -]*$ + max: 30 + shortDescription: Name + id: a0a4c5de-fad5-4901-9b48-3e3a72eb9763 + - type: EmailAddressField + name: applicantEmail + title: Email address + hint: Example of a hint + options: + required: true + customValidationMessages: + string.empty: Enter your email address + string.email: Enter an email address in the correct format, like name@example.com + schema: + regex: ^\w+([.-]\w+)*@[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*(\.[a-zA-Z]{2,})+$ + shortDescription: Email address + id: da81e4a3-1557-4a1c-9be3-be0d0d96b767 + - type: TelephoneNumberField + name: applicantMobile + title: Mobile number + options: + required: true + customValidationMessages: + string.empty: Enter a mobile number + string.pattern.base: Enter a telephone number, like 01632 960 001, 07700 900 982 + or +44 0808 157 0192 + autocomplete: tel + schema: + regex: ^\+?[0-9\s()-]{10,}$ + shortDescription: Mobile number + id: b3bce07d-4897-4124-a965-7ca02d9356a5 + - type: Html + name: applicantBusinessAddressHeader + title: Html + content:

Business address

+ id: 601ef0e8-bfe6-431f-b11f-1ca851edae3c + options: {} + schema: {} + - type: UkAddressField + title: Address + name: applicantBusinessAddress + shortDescription: Address + id: 509a72f9-4c5a-4375-a58d-b056094fd7b9 + options: {} + schema: {} + id: 089374a5-13e7-494d-994e-9b38c4b7392c + - title: Check your answers + path: /summary + controller: CheckResponsesPageController + id: aa34c1e0-e119-4e69-ab91-06d6b092055e + - title: Confirm and send + path: /declaration + controller: DeclarationPageController + view: declaration-page.html + id: 8ee401e7-76c5-456b-a1f4-d84fca115e01 +lists: + - title: RadiosField List + name: radiosFieldList + type: string + items: + - text: Option one + description: This is a hint - this option leads to a conditional page + value: radiosFieldOption-A1 + id: 277989e5-7414-47d0-93d5-b8b6469daeea + - text: Option two + description: Another hint + value: radiosFieldOption-A2 + id: 6a18e5e5-a705-4f8d-9139-678400ac35c8 + - text: Option three + value: radiosFieldOption-A3 + id: 69367b70-028c-449c-9031-4a35bb949016 + - text: None of the above + value: radiosFieldOption-A4 + id: 2349cdc9-6aa0-43f6-888c-2f9f54f8ce80 + id: 57f1e7a6-d27a-4144-ace6-6ddacad103df + - title: SelectField List + name: selectFieldList + type: string + items: + - text: Option one + value: selectFieldOption-A1 + id: 82e3ed58-0236-4217-971e-3e78d79c1e53 + - text: Option two + value: selectFieldOption-A2 + id: 4603103f-402c-4944-8830-4a73e8495fa6 + - text: Option three + value: selectFieldOption-A3 + id: 889b5fb5-7f3a-49f2-ad37-3374f1d94164 + - text: Option four + value: selectFieldOption-A4 + id: b862e383-40bb-488f-b7b9-8247f8a69d01 + - text: Option five + value: selectFieldOption-A5 + id: cc4c8808-a3f6-435a-a815-ee056d230686 + - text: Option six + value: selectFieldOption-A6 + id: 6ca32a1e-df1f-4482-bd95-41bc31a1b092 + - text: Option seven + value: selectFieldOption-A7 + id: dbb1a1b4-9a0a-4b04-a442-8fb121c51f0b + - text: None of the above + value: selectFieldOption-A8 + id: 6420f153-54e7-4dbd-abbe-4416a4b2317a + id: d43f6210-34f7-4619-a6cc-30fd579ae85f + - title: CheckboxesField List + name: checkboxesFieldList + type: string + items: + - text: Option one + description: Example hint + value: checkboxesFieldOption-A1 + id: 21a7fe17-097f-49d1-b82d-63c1fe3de394 + - text: Option two + description: Hint two + value: checkboxesFieldOption-A2 + id: 12ab72d8-dcca-48e0-b898-9b2553c2343b + - text: Option three + value: checkboxesFieldOption-A3 + id: 7da1ec83-d30e-49ad-80f9-f433969b2e23 + id: aae88d9a-2b7b-4a49-a7f4-1482d86e6be6 + - title: Country + name: countryList + type: string + items: + - text: Afghanistan + value: AFG + id: e54a5643-df33-4547-bd76-9d9450fc76e7 + - text: Benin + value: BEN + id: 86b90a31-fcd3-44dc-946b-ebde377ce007 + - text: Cambodia + value: KHM + id: 7ed1bcf8-1a90-4f53-8be1-16725aa68576 + - text: Denmark + value: DNK + id: 9aae2583-f9e0-4ad5-bedb-43a2185bb704 + - text: England + value: ENG + id: dc310792-26e3-4946-b584-0cdb8a373e20 + - text: France + value: FRA + id: fd549a18-7f95-4847-a358-f425751ff6c1 + - text: Germany + value: DEU + id: 2458c906-9fdf-4ac9-ba4b-69ee7267d956 + - text: Hungary + value: HUN + id: a145b214-1697-4d3f-a7f8-199559d35584 + - text: Ireland + value: IRL + id: 5fd5a0ce-a14f-4a2c-9b2b-2470d8c2fb3e + - text: Jamaica + value: JAM + id: 5d726070-2373-43eb-aa3c-9515d22b4989 + - text: Kenya + value: KEN + id: 1d51f459-74e2-4e58-b87c-52dbcd827e8e + - text: Laos + value: LAO + id: 4e3b899d-ed4c-4a13-860e-92d73d5ea31f + - text: Mexico + value: MEX + id: cc47160a-843b-46d0-bed3-24672ce5c507 + - text: Netherlands + value: NLD + id: e862d8be-2c92-4e52-ab1c-a0cc33efc64c + - text: Oman + value: OMN + id: c712bc31-09dc-4e0e-82f2-984010055b65 + - text: Pakistan + value: PAK + id: 16f8c3e3-dcab-4186-9807-b3a6006e4bbc + - text: Qatar + value: QAT + id: 1e893c47-a513-46fc-9997-b6373a71daa7 + - text: Romania + value: ROU + id: 1f8968d7-72f6-45c0-b9bf-3bdbb203d995 + - text: Spain + value: ESP + id: 2c70c05b-59d1-430a-8fb0-d484a95c8a56 + - text: Thailand + value: THA + id: b5103529-2823-42fd-82e1-9c02c17f8348 + - text: Uganda + value: UGA + id: 8ef9c505-6d22-4ada-8d3f-2bb6bfba3062 + - text: Vietnam + value: VNM + id: 7589d2d7-c688-4597-8c87-de070229defd + - text: Wales + value: WLS + id: c36e0c1e-b2a8-4f14-9f73-0f959e2dfb39 + - text: Yemen + value: YEM + id: d7ec2f7c-e548-463a-95c2-0f386bfd56e1 + - text: Zimbabwe + value: ZWE + id: 893ff486-07ab-4615-b0fd-2ecd664c47f2 + id: bf5432a9-a4b1-4e01-b4c8-3052ad4be056 +sections: + - title: Eligibility + name: EligibilitySection + hideTitle: false +conditions: + - id: 048e4fdd-6c54-4a9d-9bc5-c9d90470b07b + displayName: continueNo + items: + - id: 04273b55-2931-45e1-81d2-5005fac14034 + componentId: 33b2a118-efb3-45b3-a926-22fed2360bf7 + operator: is + type: BooleanValue + value: false + - id: 7fd31ec4-1e35-4ed8-9f81-c41928f4c8a7 + displayName: choiceOne + items: + - id: adab7eae-e062-4bc1-8dc0-f62ff06119ff + componentId: b48a816e-a810-4346-a111-5c7a0cac8a5b + operator: is + type: ListItemRef + value: + listId: 57f1e7a6-d27a-4144-ace6-6ddacad103df + itemId: 277989e5-7414-47d0-93d5-b8b6469daeea +schema: 2 diff --git a/localstack/start-localstack.sh b/localstack/start-localstack.sh index 8db136a2..970bb1da 100755 --- a/localstack/start-localstack.sh +++ b/localstack/start-localstack.sh @@ -16,10 +16,12 @@ aws --endpoint-url=http://localhost:4566 s3api put-bucket-versioning \ --bucket form-definition-storage \ --versioning-configuration Status=Enabled -aws --endpoint-url=http://localhost:4566 s3 mb s3://form-definition-storage -aws --endpoint-url=http://localhost:4566 s3api put-bucket-versioning \ - --bucket form-definition-storage \ - --versioning-configuration Status=Enabled +# Forms config bucket - stores YAML form definitions for seeding on startup (FORMS_CONFIG_BUCKET_NAME) +aws --endpoint-url=http://localhost:4566 s3 mb s3://forms-config + +# Seed form YAML definitions into forms-config bucket +aws --endpoint-url=http://localhost:4566 s3 cp \ + /etc/localstack/forms/ s3://forms-config/ --recursive # queues aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name cdp-clamav-results diff --git a/package-lock.json b/package-lock.json index 81e15d02..6e81b224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "mongodb": "7.1.0", "pino": "9.14.0", "pino-pretty": "13.1.3", - "proxy-agent": "6.5.0" + "proxy-agent": "6.5.0", + "yaml": "2.8.3" }, "devDependencies": { "@babel/cli": "7.28.6", @@ -3511,13 +3512,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3546,19 +3540,6 @@ "node": ">= 4" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4039,6 +4020,30 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -6963,14 +6968,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -13259,14 +13261,13 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -16974,10 +16975,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 07600364..a0a76b36 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "setup:husky": "node -e \"try { (await import('husky')).default() } catch (e) { if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e }\" --input-type module", "generate:jwt_secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", "generate:token": "node scripts/generate-token.js", - "generate:token:save": "node scripts/generate-token.js --save" + "generate:token:save": "node scripts/generate-token.js --save", + "upload:form-config": "node scripts/upload-form-config.js" }, "dependencies": { "@aws-sdk/client-s3": "3.1009.0", @@ -59,7 +60,8 @@ "mongodb": "7.1.0", "pino": "9.14.0", "pino-pretty": "13.1.3", - "proxy-agent": "6.5.0" + "proxy-agent": "6.5.0", + "yaml": "2.8.3" }, "devDependencies": { "@babel/cli": "7.28.6", diff --git a/scripts/upload-form-config.js b/scripts/upload-form-config.js new file mode 100644 index 00000000..8b795f35 --- /dev/null +++ b/scripts/upload-form-config.js @@ -0,0 +1,69 @@ +/** + * Upload a form YAML definition to the local S3 forms-config bucket. + * + * Usage: + * npm run upload:form-config -- + * + * Examples: + * npm run upload:form-config -- 0.0.5 /localstack/forms/example-grant-with-auth.yaml + * npm run upload:form-config -- 1.2.0 /path/to/farm-payments.yaml + * + * The slug is derived from the filename (without .yaml extension). + * Uploads to: s3://///grants-ui/.yaml + * + * Reads FORMS_CONFIG_BUCKET_NAME, S3_ENDPOINT, and AWS credentials from .env + */ + +import { readFile } from 'fs/promises' +import { basename, extname, resolve } from 'path' + +import 'dotenv/config' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' + +const [version, filePath] = process.argv.slice(2) + +if (!version || !filePath) { + console.error('Usage: npm run upload:form-config -- ') + console.error(' e.g. npm run upload:form-config -- 0.0.5 ./forms/example-grant-with-auth.yaml') + process.exit(1) +} + +const bucket = process.env.FORMS_CONFIG_BUCKET_NAME +const endpoint = process.env.S3_ENDPOINT + +if (!bucket) { + console.error('FORMS_CONFIG_BUCKET_NAME is not set. Add it to your .env file.') + process.exit(1) +} + +const slug = basename(filePath, extname(filePath)) +const key = `${slug}/${version}/grants-ui/${slug}.yaml` +const absolutePath = resolve(filePath) + +const s3 = new S3Client({ + region: process.env.AWS_REGION ?? 'eu-west-2', + ...(endpoint && { + endpoint, + forcePathStyle: true, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? 'test', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? 'test' + } + }) +}) + +console.log(`Uploading ${absolutePath}`) +console.log(` → s3://${bucket}/${key}`) + +const body = await readFile(absolutePath) + +await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: 'application/yaml' + }) +) + +console.log('Done.') diff --git a/src/api/forms/repositories/form-metadata-repository.js b/src/api/forms/repositories/form-metadata-repository.js index d3d69a95..faba143f 100644 --- a/src/api/forms/repositories/form-metadata-repository.js +++ b/src/api/forms/repositories/form-metadata-repository.js @@ -199,10 +199,9 @@ export async function getBySlug(slug, session) { return document } catch (err) { - logger.error(err, `[getFormBySlug] Getting form with slug ${slug} failed - ${getErrorMessage(err)}`) - - if (err instanceof Error && !Boom.isBoom(err)) { - throw Boom.internal(err) + if (!Boom.isBoom(err)) { + logger.error(err, `[getFormBySlug] Getting form with slug ${slug} failed - ${getErrorMessage(err)}`) + throw Boom.internal(/** @type {Error} */ (err)) } throw err diff --git a/src/api/forms/service/definition.js b/src/api/forms/service/definition.js index ac64e355..a283f659 100644 --- a/src/api/forms/service/definition.js +++ b/src/api/forms/service/definition.js @@ -67,8 +67,6 @@ export async function updateDraftFormDefinition(formId, definition, author) { try { await session.withTransaction(async () => { - logger.info(`Updating form definition (draft) for form ID ${formId}`) - await formDefinition.update(formId, definition, session, schema) const updatedMetadata = await formMetadata.updateAudit(formId, author, session) diff --git a/src/api/forms/service/s3-seeder.js b/src/api/forms/service/s3-seeder.js new file mode 100644 index 00000000..c2117460 --- /dev/null +++ b/src/api/forms/service/s3-seeder.js @@ -0,0 +1,223 @@ +import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' +import Boom from '@hapi/boom' +import { parse } from 'yaml' + +import { config } from '~/src/config/index.js' +import { createLogger } from '~/src/helpers/logging/logger.js' +import * as formMetadataRepo from '~/src/api/forms/repositories/form-metadata-repository.js' +import { createForm } from '~/src/api/forms/service/index.js' +import { createLiveFromDraft, updateDraftFormDefinition } from '~/src/api/forms/service/definition.js' + +const logger = createLogger() + +/** @type {FormMetadataAuthor} */ +const systemAuthor = { + id: 'system', + displayName: 'System Seeder' +} + +/** + * Creates an S3 client configured for the forms config bucket + * @returns {S3Client} + */ +function getFormsConfigS3Client() { + return new S3Client({ + region: config.get('awsRegion'), + ...(config.get('s3Endpoint') && { + endpoint: config.get('s3Endpoint'), + forcePathStyle: true + }) + }) +} + +/** + * Extracts form metadata fields from a YAML form definition. + * Falls back to configured defaults when values are not set in the definition. + * Note: "metadata" here means the Mongo form-metadata document fields, which + * is distinct from the "metadata" object within the form definition itself. + * @param {Record} formDef - Parsed form definition from YAML + * @param {string} slug + * @returns {FormMetadataInputWithSlug} + */ +function extractMetadata(formDef, slug) { + const defMeta = formDef.metadata ?? {} + + return { + title: formDef.name, + organisation: defMeta.organisation || config.get('defaultFormOrganisation'), + teamName: defMeta.teamName || config.get('defaultFormTeamName'), + teamEmail: defMeta.teamEmail || config.get('defaultFormTeamEmail'), + notificationEmail: defMeta.notificationEmail || config.get('defaultFormNotificationEmail'), + slug + } +} + +/** + * Compares two semver strings (e.g. "0.0.5" vs "0.1.0"). + * Returns negative if a < b, positive if a > b, 0 if equal. + * @param {string} a + * @param {string} b + * @returns {number} + */ +function compareSemver(a, b) { + const partsA = a.split('.').map(Number) + const partsB = b.split('.').map(Number) + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0) + if (diff !== 0) { + return diff + } + } + return 0 +} + +/** + * Lists all version strings available for a slug in S3. + * Keys follow the pattern: {slug}/{version}/grants-ui/{slug}.yaml + * @param {string} slug + * @param {string} bucket + * @param {S3Client} s3Client + * @returns {Promise} Sorted array of version strings (ascending) + */ +async function listVersionsForSlug(slug, bucket, s3Client) { + const prefix = `${slug}/` + const response = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/' })) + + // CommonPrefixes contains entries like "example-grant-with-auth/0.0.5/" + const versions = (response.CommonPrefixes ?? []) + .map((p) => p.Prefix?.replace(prefix, '').replace('/', '') ?? '') + .filter(Boolean) + .sort(compareSemver) + + return versions +} + +/** + * Fetches and parses a YAML form definition from S3. + * Key format: {slug}/{version}/grants-ui/{slug}.yaml + * Automatically resolves the latest version by listing the bucket. + * @param {string} slug + * @param {S3Client} s3Client + * @returns {Promise<{ formDef: FormDefinitionWithMetadata, version: string }>} + */ +async function fetchFormDefinitionFromS3(slug, s3Client) { + const bucket = config.get('formsConfigBucket') + + const versions = await listVersionsForSlug(slug, bucket, s3Client) + + if (!versions.length) { + throw new Error(`No versions found in s3://${bucket}/${slug}/`) + } + + const latestVersion = versions[versions.length - 1] + const key = `${slug}/${latestVersion}/grants-ui/${slug}.yaml` + + logger.info(`[s3Seeder] Fetching s3://${bucket}/${key} (latest of: ${versions.join(', ')})`) + + const response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key })) + + if (!response.Body) { + throw new Error(`Empty response body for s3://${bucket}/${key}`) + } + + const bodyString = await response.Body.transformToString() + const parsed = parse(bodyString) + + if (!parsed || typeof parsed !== 'object') { + throw new Error(`Failed to parse YAML for slug '${slug}': unexpected type ${typeof parsed}`) + } + + return { formDef: /** @type {FormDefinitionWithMetadata} */ (parsed), version: latestVersion } +} + +/** + * Returns true if a form with the given slug already exists in MongoDB. + * @param {string} slug + * @returns {Promise} + */ +async function slugExistsInMongo(slug) { + try { + await formMetadataRepo.getBySlug(slug) + return true + } catch (err) { + if (Boom.isBoom(err) && err.output.statusCode === 404) { + return false + } + throw err + } +} + +/** + * Seeds a single form from S3 into MongoDB. + * Skips if the slug already exists. Creates the form, loads the definition + * from S3 as a draft, then publishes it live. + * @param {string} slug + * @param {S3Client} s3Client + */ +async function seedForm(slug, s3Client) { + if (await slugExistsInMongo(slug)) { + logger.info(`[s3Seeder] Slug '${slug}' already exists in MongoDB, skipping`) + return + } + + logger.info(`[s3Seeder] Seeding form '${slug}' from S3`) + + const { formDef, version } = await fetchFormDefinitionFromS3(slug, s3Client) + const metadataInput = extractMetadata(formDef, slug) + + // Create the form record with an empty definition and draft state + const form = await createForm({ ...metadataInput, slug }, systemAuthor) + + // Replace the empty draft with the definition loaded from S3 + await updateDraftFormDefinition(form.id, formDef, systemAuthor) + + // Publish the draft as live + await createLiveFromDraft(form.id, systemAuthor) + + logger.info(`[s3Seeder] Successfully seeded form '${slug}' v${version} (id: ${form.id})`) +} + +/** + * Seeds forms from S3 into MongoDB on startup. + * Reads FORMS_API_SLUGS and FORMS_CONFIG_BUCKET_NAME from config. + * Skips silently if either is not configured. Errors for individual slugs + * are logged but do not prevent remaining slugs or the server from starting. + */ +export async function seedFormsFromS3() { + const slugsRaw = config.get('formsApiSlugs') + const bucket = config.get('formsConfigBucket') + + if (!slugsRaw || !bucket) { + logger.info('[s3Seeder] FORMS_API_SLUGS or FORMS_CONFIG_BUCKET_NAME not set, skipping S3 form seeding') + return + } + + const slugs = slugsRaw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + if (!slugs.length) { + logger.info('[s3Seeder] FORMS_API_SLUGS is empty, skipping S3 form seeding') + return + } + + logger.info(`[s3Seeder] Seeding ${slugs.length} form(s) from S3 bucket '${bucket}': ${slugs.join(', ')}`) + + const s3Client = getFormsConfigS3Client() + + for (const slug of slugs) { + try { + await seedForm(slug, s3Client) + } catch (err) { + logger.error(err, `[s3Seeder] Failed to seed form '${slug}', continuing with remaining slugs`) + } + } + + logger.info('[s3Seeder] S3 form seeding complete') +} + +/** + * @import { FormMetadataAuthor } from '@defra/forms-model' + * @import { FormMetadataInputWithSlug, FormDefinitionWithMetadata } from '~/src/api/types.js' + */ diff --git a/src/api/server.js b/src/api/server.js index c7db1843..7927c2ef 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -9,6 +9,7 @@ import { failAction } from '~/src/helpers/fail-action.js' import { requestLogger } from '~/src/helpers/logging/request-logger.js' import { requestTracing } from '~/src/helpers/request-tracing.js' import { prepareDb } from '~/src/mongo.js' +import { seedFormsFromS3 } from '~/src/api/forms/service/s3-seeder.js' import { auth } from '~/src/plugins/auth/index.js' import { queryHandler } from '~/src/plugins/query-handler/index.js' import { router } from '~/src/plugins/router.js' @@ -72,6 +73,7 @@ export async function createServer() { } await prepareDb(server.logger) + await seedFormsFromS3() await server.register(transformErrors) await server.register(router) diff --git a/src/config/index.js b/src/config/index.js index 1ecbe213..52186643 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -186,6 +186,42 @@ export const config = convict({ format: Boolean, default: false, env: 'FEATURE_FLAG_USE_ENTITLEMENT_API' + }, + formsConfigBucket: { + doc: 'S3 bucket name containing YAML form definition config files for seeding on startup', + format: String, + default: '', + env: 'FORMS_CONFIG_BUCKET_NAME' + }, + formsApiSlugs: { + doc: 'Comma-separated list of form slugs to seed from S3 on startup (e.g. "example-grant,farm-payments")', + format: String, + default: '', + env: 'FORMS_API_SLUGS' + }, + defaultFormOrganisation: { + doc: 'Default organisation for forms seeded from S3 (used when not set in the form definition metadata)', + format: String, + default: 'Defra', + env: 'DEFAULT_FORM_ORGANISATION' + }, + defaultFormTeamName: { + doc: 'Default team name for forms seeded from S3 (used when not set in the form definition metadata)', + format: String, + default: 'Digital Delivery', + env: 'DEFAULT_FORM_TEAM_NAME' + }, + defaultFormTeamEmail: { + doc: 'Default team email for forms seeded from S3 (used when not set in the form definition metadata)', + format: String, + default: 'digitaldelivery@defra.gov.uk', + env: 'DEFAULT_FORM_TEAM_EMAIL' + }, + defaultFormNotificationEmail: { + doc: 'Default notification email for forms seeded from S3 (used when not set in the form definition metadata)', + format: String, + default: 'digitaldelivery@defra.gov.uk', + env: 'DEFAULT_FORM_NOTIFICATION_EMAIL' } }) From 0b4652116f959149683dbdf7997acb54ecf8667f Mon Sep 17 00:00:00 2001 From: David Middleton Date: Wed, 25 Mar 2026 11:53:17 +0000 Subject: [PATCH 2/3] Fix test --- src/api/forms/repositories/form-metadata-repository.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/api/forms/repositories/form-metadata-repository.js b/src/api/forms/repositories/form-metadata-repository.js index faba143f..7a4f8be5 100644 --- a/src/api/forms/repositories/form-metadata-repository.js +++ b/src/api/forms/repositories/form-metadata-repository.js @@ -199,9 +199,13 @@ export async function getBySlug(slug, session) { return document } catch (err) { - if (!Boom.isBoom(err)) { + if (Boom.isBoom(err)) { + throw err + } + + if (err instanceof Error) { logger.error(err, `[getFormBySlug] Getting form with slug ${slug} failed - ${getErrorMessage(err)}`) - throw Boom.internal(/** @type {Error} */ (err)) + throw Boom.internal(err) } throw err From 2d234bff5be54983c15c3a51e32740484dbec292 Mon Sep 17 00:00:00 2001 From: David Middleton Date: Wed, 25 Mar 2026 16:04:34 +0000 Subject: [PATCH 3/3] Add coverage --- src/api/forms/service/s3-seeder.js | 4 +- src/api/forms/service/s3-seeder.test.js | 303 ++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/api/forms/service/s3-seeder.test.js diff --git a/src/api/forms/service/s3-seeder.js b/src/api/forms/service/s3-seeder.js index c2117460..c11afcfe 100644 --- a/src/api/forms/service/s3-seeder.js +++ b/src/api/forms/service/s3-seeder.js @@ -10,6 +10,8 @@ import { createLiveFromDraft, updateDraftFormDefinition } from '~/src/api/forms/ const logger = createLogger() +const HTTP_NOT_FOUND = 404 + /** @type {FormMetadataAuthor} */ const systemAuthor = { id: 'system', @@ -140,7 +142,7 @@ async function slugExistsInMongo(slug) { await formMetadataRepo.getBySlug(slug) return true } catch (err) { - if (Boom.isBoom(err) && err.output.statusCode === 404) { + if (Boom.isBoom(err) && err.output.statusCode === HTTP_NOT_FOUND) { return false } throw err diff --git a/src/api/forms/service/s3-seeder.test.js b/src/api/forms/service/s3-seeder.test.js new file mode 100644 index 00000000..f2d1f3cc --- /dev/null +++ b/src/api/forms/service/s3-seeder.test.js @@ -0,0 +1,303 @@ +import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' +import Boom from '@hapi/boom' +import { mockClient } from 'aws-sdk-client-mock' +import 'aws-sdk-client-mock-jest' +import { stringify } from 'yaml' + +import { config } from '~/src/config/index.js' +import * as formMetadataRepo from '~/src/api/forms/repositories/form-metadata-repository.js' +import { createLiveFromDraft, updateDraftFormDefinition } from '~/src/api/forms/service/definition.js' +import { createForm } from '~/src/api/forms/service/index.js' +import { formMetadataDocument, formMetadataOutput } from '~/src/api/forms/service/__stubs__/service.js' +import { seedFormsFromS3 } from '~/src/api/forms/service/s3-seeder.js' + +jest.mock('~/src/config/index.js', () => ({ + config: { get: jest.fn() } +})) + +jest.mock('~/src/helpers/logging/logger.js', () => ({ + createLogger: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn() + })) +})) + +jest.mock('~/src/api/forms/repositories/form-metadata-repository.js') +jest.mock('~/src/api/forms/service/index.js') +jest.mock('~/src/api/forms/service/definition.js') + +/** @type {Record} */ +const testConfig = { + awsRegion: 'eu-west-2', + s3Endpoint: undefined, + formsConfigBucket: 'test-bucket', + formsApiSlugs: 'test-form', + defaultFormOrganisation: 'Default Org', + defaultFormTeamName: 'Default Team', + defaultFormTeamEmail: 'default@example.com', + defaultFormNotificationEmail: 'notify@example.com' +} + +/** + * Sets up config.get mock with optional overrides for individual keys + * @param {Record} [overrides] + */ +function mockConfigGet(overrides = {}) { + // @ts-expect-error - test stub, key is not typed as Path + jest.mocked(config.get).mockImplementation((key) => ({ ...testConfig, ...overrides })[key]) +} + +const mockSlug = 'test-form' + +const mockFormDef = { + name: 'Test Form', + metadata: { + organisation: 'Test Org', + teamName: 'Test Team', + teamEmail: 'team@test.com', + notificationEmail: 'notify@test.com' + }, + pages: [] +} + +const mockYaml = stringify(mockFormDef) + +/** + * Builds a mock S3 GetObject response with a YAML body + * @param {string} yaml + * @returns {any} + */ +function mockS3Response(yaml) { + return { Body: { transformToString: jest.fn().mockResolvedValue(yaml) } } +} + +describe('s3-seeder', () => { + const s3Mock = mockClient(S3Client) + + beforeEach(() => { + mockConfigGet() + + jest.mocked(formMetadataRepo.getBySlug).mockRejectedValue(Boom.notFound()) + jest.mocked(createForm).mockResolvedValue(formMetadataOutput) + jest.mocked(updateDraftFormDefinition).mockResolvedValue(undefined) + jest.mocked(createLiveFromDraft).mockResolvedValue(undefined) + + s3Mock.on(ListObjectsV2Command).resolves({ + CommonPrefixes: [{ Prefix: `${mockSlug}/0.0.1/` }, { Prefix: `${mockSlug}/0.1.0/` }] + }) + s3Mock.on(GetObjectCommand).resolves(mockS3Response(mockYaml)) + }) + + afterEach(() => { + s3Mock.reset() + }) + + describe('seedFormsFromS3', () => { + describe('early exit conditions', () => { + it('skips seeding when formsApiSlugs is not configured', async () => { + mockConfigGet({ formsApiSlugs: undefined }) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + expect(s3Mock).not.toHaveReceivedCommand(ListObjectsV2Command) + }) + + it('skips seeding when formsConfigBucket is not configured', async () => { + mockConfigGet({ formsConfigBucket: undefined }) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + expect(s3Mock).not.toHaveReceivedCommand(ListObjectsV2Command) + }) + + it('skips seeding when formsApiSlugs is empty after trimming and splitting', async () => { + mockConfigGet({ formsApiSlugs: ' , , ' }) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + expect(s3Mock).not.toHaveReceivedCommand(ListObjectsV2Command) + }) + }) + + describe('version resolution', () => { + it('picks the latest semver version when multiple versions exist in S3', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ + CommonPrefixes: [ + { Prefix: `${mockSlug}/0.1.0/` }, + { Prefix: `${mockSlug}/0.0.5/` }, + { Prefix: `${mockSlug}/0.0.1/` } + ] + }) + + await seedFormsFromS3() + + expect(s3Mock).toHaveReceivedCommandWith(GetObjectCommand, { + Bucket: 'test-bucket', + Key: `${mockSlug}/0.1.0/grants-ui/${mockSlug}.yaml` + }) + }) + + it('sends ListObjectsV2 with the correct bucket, prefix, and delimiter', async () => { + await seedFormsFromS3() + + expect(s3Mock).toHaveReceivedCommandWith(ListObjectsV2Command, { + Bucket: 'test-bucket', + Prefix: `${mockSlug}/`, + Delimiter: '/' + }) + }) + + it('logs an error and continues when no versions are found for a slug', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ CommonPrefixes: [] }) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + expect(s3Mock).not.toHaveReceivedCommand(GetObjectCommand) + }) + }) + + describe('form seeding', () => { + it('skips a slug that already exists in MongoDB', async () => { + jest.mocked(formMetadataRepo.getBySlug).mockResolvedValue(formMetadataDocument) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + expect(updateDraftFormDefinition).not.toHaveBeenCalled() + expect(createLiveFromDraft).not.toHaveBeenCalled() + }) + + it('calls createForm, updateDraftFormDefinition, and createLiveFromDraft for a new slug', async () => { + await seedFormsFromS3() + + expect(createForm).toHaveBeenCalledTimes(1) + expect(updateDraftFormDefinition).toHaveBeenCalledTimes(1) + expect(createLiveFromDraft).toHaveBeenCalledTimes(1) + }) + + it('passes the form definition and system author to updateDraftFormDefinition', async () => { + await seedFormsFromS3() + + expect(updateDraftFormDefinition).toHaveBeenCalledWith( + formMetadataOutput.id, + expect.objectContaining({ name: 'Test Form' }), + expect.objectContaining({ id: 'system', displayName: 'System Seeder' }) + ) + }) + + it('publishes the draft using the form id and system author', async () => { + await seedFormsFromS3() + + expect(createLiveFromDraft).toHaveBeenCalledWith( + formMetadataOutput.id, + expect.objectContaining({ id: 'system', displayName: 'System Seeder' }) + ) + }) + + it('passes metadata extracted from the form definition to createForm', async () => { + await seedFormsFromS3() + + expect(createForm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Test Form', + slug: mockSlug, + organisation: 'Test Org', + teamName: 'Test Team', + teamEmail: 'team@test.com', + notificationEmail: 'notify@test.com' + }), + expect.objectContaining({ id: 'system' }) + ) + }) + + it('falls back to config defaults when the form definition has no metadata', async () => { + const defWithoutMeta = { name: 'Minimal Form', pages: [] } + + s3Mock.on(GetObjectCommand).resolves(mockS3Response(stringify(defWithoutMeta))) + + await seedFormsFromS3() + + expect(createForm).toHaveBeenCalledWith( + expect.objectContaining({ + organisation: 'Default Org', + teamName: 'Default Team', + teamEmail: 'default@example.com', + notificationEmail: 'notify@example.com' + }), + expect.anything() + ) + }) + + it('seeds multiple slugs from a comma-separated config value', async () => { + const secondSlug = 'another-form' + + mockConfigGet({ formsApiSlugs: `${mockSlug},${secondSlug}` }) + + s3Mock + .on(ListObjectsV2Command, { Prefix: `${secondSlug}/` }) + .resolves({ CommonPrefixes: [{ Prefix: `${secondSlug}/1.0.0/` }] }) + + s3Mock.on(GetObjectCommand).resolves(mockS3Response(mockYaml)) + + await seedFormsFromS3() + + expect(createForm).toHaveBeenCalledTimes(2) + }) + }) + + describe('error handling', () => { + it('logs an error and continues seeding remaining slugs when one slug fails', async () => { + const failingSlug = 'failing-form' + + mockConfigGet({ formsApiSlugs: `${failingSlug},${mockSlug}` }) + + s3Mock.on(ListObjectsV2Command, { Prefix: `${failingSlug}/` }).resolves({ CommonPrefixes: [] }) + + s3Mock + .on(ListObjectsV2Command, { Prefix: `${mockSlug}/` }) + .resolves({ CommonPrefixes: [{ Prefix: `${mockSlug}/0.0.1/` }] }) + + await seedFormsFromS3() + + expect(createForm).toHaveBeenCalledTimes(1) + expect(createForm).toHaveBeenCalledWith(expect.objectContaining({ slug: mockSlug }), expect.anything()) + }) + + it('does not seed when getBySlug throws a non-404 Boom error', async () => { + jest.mocked(formMetadataRepo.getBySlug).mockRejectedValue(Boom.badRequest('Unexpected error')) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + }) + + it('does not seed when getBySlug throws a generic error', async () => { + jest.mocked(formMetadataRepo.getBySlug).mockRejectedValue(new Error('DB connection failed')) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + }) + + it('logs and continues when the S3 response body is empty', async () => { + s3Mock.on(GetObjectCommand).resolves(/** @type {any} */ ({ Body: null })) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + }) + + it('logs and continues when the S3 body is not a valid YAML object', async () => { + s3Mock.on(GetObjectCommand).resolves(mockS3Response('just a plain string')) + + await seedFormsFromS3() + + expect(createForm).not.toHaveBeenCalled() + }) + }) + }) +})