diff --git a/.env.example b/.env.example
index 900e330..7c72a10 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 fa7f487..656aedd 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 0000000..3a47973
--- /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:
+
+ - you have accepted your offer
+ - the Rural Payments Agency confirms your start date
+
+
+ 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: |+
+
+ - AutocompleteField
+ - CheckboxesField
+ - DatePartsField
+ - EmailAddressField
+ - MonthYearField
+ - MultilineTextField
+ - NumberField
+ - RadiosField
+ - SelectField
+ - TelephoneNumberField
+ - TextField
+ - UkAddressField
+ - YesNoField
+
+
+ id: dc4b8bac-ac3d-43ba-ae28-575997f8836d
+ options: {}
+ schema: {}
+ - name: startPagesDetails
+ title: Page types
+ type: Details
+ content: |+
+
+ - Summary page
+ - Declaration page
+ - Confirmation page
+ - Terminal page
+
+
+ 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 8db136a..970bb1d 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 81e15d0..6e81b22 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 0760036..a0a76b3 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 0000000..8b795f3
--- /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 d3d69a9..7a4f8be 100644
--- a/src/api/forms/repositories/form-metadata-repository.js
+++ b/src/api/forms/repositories/form-metadata-repository.js
@@ -199,9 +199,12 @@ export async function getBySlug(slug, session) {
return document
} catch (err) {
- logger.error(err, `[getFormBySlug] Getting form with slug ${slug} failed - ${getErrorMessage(err)}`)
+ if (Boom.isBoom(err)) {
+ throw err
+ }
- if (err instanceof Error && !Boom.isBoom(err)) {
+ if (err instanceof Error) {
+ logger.error(err, `[getFormBySlug] Getting form with slug ${slug} failed - ${getErrorMessage(err)}`)
throw Boom.internal(err)
}
diff --git a/src/api/forms/service/definition.js b/src/api/forms/service/definition.js
index ac64e35..a283f65 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 0000000..c11afcf
--- /dev/null
+++ b/src/api/forms/service/s3-seeder.js
@@ -0,0 +1,225 @@
+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()
+
+const HTTP_NOT_FOUND = 404
+
+/** @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 === HTTP_NOT_FOUND) {
+ 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/forms/service/s3-seeder.test.js b/src/api/forms/service/s3-seeder.test.js
new file mode 100644
index 0000000..f2d1f3c
--- /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()
+ })
+ })
+ })
+})
diff --git a/src/api/server.js b/src/api/server.js
index c7db184..7927c2e 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 1ecbe21..5218664 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'
}
})