diff --git a/package-lock.json b/package-lock.json index 2d9d25ced..ec3da6f9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,9 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@aws-sdk/client-s3": "^3.679.0", "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.545", + "@defra/forms-engine-plugin": "^3.0.0", + "@defra/forms-model": "^3.0.550", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -178,83 +177,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -380,425 +302,6 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.879.0.tgz", - "integrity": "sha512-1bD2Do/OdCIzl72ncHKYamDhPijUErLYpuLvciyYD4Ywt4cVLHjWtVIqb22XOOHYYHE3NqHMd4uRhvXMlsBRoQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/credential-provider-node": "3.879.0", - "@aws-sdk/middleware-bucket-endpoint": "3.873.0", - "@aws-sdk/middleware-expect-continue": "3.873.0", - "@aws-sdk/middleware-flexible-checksums": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-location-constraint": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-sdk-s3": "3.879.0", - "@aws-sdk/middleware-ssec": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/signature-v4-multi-region": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/eventstream-serde-browser": "^4.0.5", - "@smithy/eventstream-serde-config-resolver": "^4.1.3", - "@smithy/eventstream-serde-node": "^4.0.5", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-blob-browser": "^4.0.5", - "@smithy/hash-node": "^4.0.5", - "@smithy/hash-stream-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/md5-js": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.879.0.tgz", - "integrity": "sha512-+Pc3OYFpRYpKLKRreovPM63FPPud1/SF9vemwIJfz6KwsBCJdvg7vYD1xLSIp5DVZLeetgf4reCyAA5ImBfZuw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.879.0.tgz", - "integrity": "sha512-JgG7A8SSbr5IiCYL8kk39Y9chdSB5GPwBorDW8V8mr19G9L+qd6ohED4fAocoNFaDnYJ5wGAHhCfSJjzcsPBVQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.879.0.tgz", - "integrity": "sha512-2hM5ByLpyK+qORUexjtYyDZsgxVCCUiJQZRMGkNXFEGz6zTpbjfTIWoh3zRgWHEBiqyPIyfEy50eIF69WshcuA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.879.0.tgz", - "integrity": "sha512-07M8zfb73KmMBqVO5/V3Ea9kqDspMX0fO0kaI1bsjWI6ngnMye8jCE0/sIhmkVAI0aU709VA0g+Bzlopnw9EoQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/credential-provider-env": "3.879.0", - "@aws-sdk/credential-provider-http": "3.879.0", - "@aws-sdk/credential-provider-process": "3.879.0", - "@aws-sdk/credential-provider-sso": "3.879.0", - "@aws-sdk/credential-provider-web-identity": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.879.0.tgz", - "integrity": "sha512-FYaAqJbnSTrVL2iZkNDj2hj5087yMv2RN2GA8DJhe7iOJjzhzRojrtlfpWeJg6IhK0sBKDH+YXbdeexCzUJvtA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.879.0", - "@aws-sdk/credential-provider-http": "3.879.0", - "@aws-sdk/credential-provider-ini": "3.879.0", - "@aws-sdk/credential-provider-process": "3.879.0", - "@aws-sdk/credential-provider-sso": "3.879.0", - "@aws-sdk/credential-provider-web-identity": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.879.0.tgz", - "integrity": "sha512-7r360x1VyEt35Sm1JFOzww2WpnfJNBbvvnzoyLt7WRfK0S/AfsuWhu5ltJ80QvJ0R3AiSNbG+q/btG2IHhDYPQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.879.0.tgz", - "integrity": "sha512-gd27B0NsgtKlaPNARj4IX7F7US5NuU691rGm0EUSkDsM7TctvJULighKoHzPxDQlrDbVI11PW4WtKS/Zg5zPlQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.879.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/token-providers": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.879.0.tgz", - "integrity": "sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.879.0.tgz", - "integrity": "sha512-DDSV8228lQxeMAFKnigkd0fHzzn5aauZMYC3CSj6e5/qE7+9OwpkUcjHfb7HZ9KWG6L2/70aKZXHqiJ4xKhOZw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@smithy/core": "^3.9.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.879.0.tgz", - "integrity": "sha512-7+n9NpIz9QtKYnxmw1fHi9C8o0GrX8LbBR4D50c7bH6Iq5+XdSuL5AFOWWQ5cMD0JhqYYJhK/fJsVau3nUtC4g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.879.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.879.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.879.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.9.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.19", - "@smithy/middleware-retry": "^4.1.20", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.27", - "@smithy/util-defaults-mode-node": "^4.0.27", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.879.0.tgz", - "integrity": "sha512-47J7sCwXdnw9plRZNAGVkNEOlSiLb/kR2slnDIHRK9NB/ECKsoqgz5OZQJ9E2f0yqOs8zSNJjn3T01KxpgW8Qw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.879.0", - "@aws-sdk/nested-clients": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz", - "integrity": "sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.879.0.tgz", - "integrity": "sha512-A5KGc1S+CJRzYnuxJQQmH1BtGsz46AgyHkqReKfGiNQA8ET/9y9LQ5t2ABqnSBHHIh3+MiCcQSkUZ0S3rTodrQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@aws-sdk/client-sns": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sns/-/client-sns-3.876.0.tgz", @@ -927,166 +430,12 @@ "node_modules/@aws-sdk/credential-provider-env": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.876.0.tgz", - "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", - "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", - "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", - "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-ini": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", - "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", - "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.876.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/token-providers": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", - "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.873.0.tgz", - "integrity": "sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.873.0.tgz", - "integrity": "sha512-GIqoc8WgRcf/opBOZXFLmplJQKwOMjiOMmDz9gQkaJ8FiVJoAp8EGVmK2TOWZMQUYsavvHYsHaor5R2xwPoGVg==", + "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/property-provider": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1094,64 +443,67 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.879.0.tgz", - "integrity": "sha512-U1rcWToy2rlQPQLsx5h73uTC1XYo/JpnlJGCc3Iw7b1qrK8Mke4+rgMPKCfnXELD5TTazGrbT03frxH4Y1Ycvw==", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", + "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.879.0", + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.1.4", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/property-provider": "^4.0.5", "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.4.10", "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", + "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", + "@aws-sdk/credential-provider-env": "3.876.0", + "@aws-sdk/credential-provider-http": "3.876.0", + "@aws-sdk/credential-provider-process": "3.876.0", + "@aws-sdk/credential-provider-sso": "3.876.0", + "@aws-sdk/credential-provider-web-identity": "3.876.0", + "@aws-sdk/nested-clients": "3.876.0", "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.7", "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", - "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", + "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/credential-provider-env": "3.876.0", + "@aws-sdk/credential-provider-http": "3.876.0", + "@aws-sdk/credential-provider-ini": "3.876.0", + "@aws-sdk/credential-provider-process": "3.876.0", + "@aws-sdk/credential-provider-sso": "3.876.0", + "@aws-sdk/credential-provider-web-identity": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1159,13 +511,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.873.0.tgz", - "integrity": "sha512-r+hIaORsW/8rq6wieDordXnA/eAu7xAPLue2InhoEX6ML7irP52BgiibHLpt9R0psiCzIHhju8qqKa4pJOrmiw==", + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", + "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1173,13 +528,18 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { + "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", - "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", + "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/client-sso": "3.876.0", + "@aws-sdk/core": "3.876.0", + "@aws-sdk/token-providers": "3.876.0", "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1187,14 +547,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", - "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", + "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "3.876.0", + "@aws-sdk/nested-clients": "3.876.0", "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", + "@smithy/property-provider": "^4.0.5", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1202,64 +564,43 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.879.0.tgz", - "integrity": "sha512-ZTpLr2AbZcCsEzu18YCtB8Tp8tjAWHT0ccfwy3HiL6g9ncuSMW+7BVi1hDYmBidFwpPbnnIMtM0db3pDMR6/WA==", + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", + "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.879.0", "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3/node_modules/@aws-sdk/core": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", - "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", + "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.9.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.5.0", "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-ssec": { + "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.873.0.tgz", - "integrity": "sha512-AF55J94BoiuzN7g3hahy0dXTVZahVi8XxRBLgzNp6yQf0KTng+hb/V9UQZVYY1GZaDczvvvnqC54RGe9OZZ9zQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", + "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, @@ -1351,23 +692,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.879.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.879.0.tgz", - "integrity": "sha512-MDsw0EWOHyKac75X3gD8tLWtmPuRliS/s4IhWRhsdDCU13wewHIs5IlA5B65kT6ISf49yEIalEH3FHUSVqdmIQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.879.0", - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/token-providers": { "version": "3.876.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.876.0.tgz", @@ -1399,18 +723,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", - "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/util-endpoints": { "version": "3.873.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.873.0.tgz", @@ -3592,9 +2904,9 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-2.1.9.tgz", - "integrity": "sha512-VXLP/0JyTVy62jNqvaXqpqsfkapI1coLQ+pm8CJg6z0JHNkdoZBeOs+TbhZN2a6d1GZSAodqqd6AMG+NRtHs3g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.0.tgz", + "integrity": "sha512-LQGCiGfOcZFtpLZNW0FXiVbYb1nc5gQJS26IGTcrLE0oma6iEkVYmw3upGmZ/IMU5YZtu/3tJGusQK4/ASK4Ig==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -3661,9 +2973,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.545", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.545.tgz", - "integrity": "sha512-jPAJGgFomQz2t1d8m+ttyijdpNYzoIxX17MkCtTl98cJ4QFOLAJOkZB442MlrhEhopLqCDxYXoIxNJHz6w8+Vg==", + "version": "3.0.550", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.550.tgz", + "integrity": "sha512-Sy0inVCAbpIGDF//JqyKx8QB1IsoFw6KlV23EXFrFboC2KPAA5ohjmH6nhO10ypeNaPLNlaQJnE2U4OcZkEvOg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -5785,31 +5097,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/config-resolver": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", @@ -5877,76 +5164,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.5.tgz", - "integrity": "sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.5.tgz", - "integrity": "sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.3.tgz", - "integrity": "sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.5.tgz", - "integrity": "sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.5.tgz", - "integrity": "sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/fetch-http-handler": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", @@ -5963,21 +5180,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.5.tgz", - "integrity": "sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/hash-node": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", @@ -5993,20 +5195,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.5.tgz", - "integrity": "sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/invalid-dependency": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", @@ -6032,20 +5220,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", - "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/middleware-content-length": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", @@ -6506,20 +5680,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 317acbf41..709a1a9e5 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.864.0", - "@defra/forms-engine-plugin": "^2.1.9", - "@defra/forms-model": "^3.0.545", - "@aws-sdk/client-s3": "^3.679.0", + "@defra/forms-engine-plugin": "^3.0.0", + "@defra/forms-model": "^3.0.550", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/config/index.ts b/src/config/index.ts index 63c823cbd..d1efd1fd7 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -286,6 +286,11 @@ export const config = convict({ format: String, default: '', env: 'GOOGLE_ANALYTICS_TRACKING_ID' + }, + saveAndExitExpiryDays: { + format: Number, + default: 30, + env: 'SAVE_AND_EXIT_EXPIRY_IN_DAYS' } }) diff --git a/src/server/helpers/error-helper.js b/src/server/helpers/error-helper.js new file mode 100644 index 000000000..e3f53a7a7 --- /dev/null +++ b/src/server/helpers/error-helper.js @@ -0,0 +1,21 @@ +import Joi from 'joi' + +/** + * @param {string} fieldName + * @param {string} message + * @returns {Joi.ValidationError} + */ +export function createJoiError(fieldName, message) { + return new Joi.ValidationError( + message, + [ + { + message, + path: [fieldName], + type: 'custom', + context: { key: fieldName, label: fieldName } + } + ], + {} + ) +} diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 2f0078979..b38670206 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -4,12 +4,12 @@ import { UploadStatus, type UploadStatusResponse } from '@defra/forms-engine-plugin/engine/types.js' +import { FormStatus } from '@defra/forms-engine-plugin/types' import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, getFormMetadata diff --git a/src/server/index.ts b/src/server/index.ts index 175b05454..0071063c9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,15 @@ import { join, parse } from 'path' import plugin from '@defra/forms-engine-plugin' +import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' -import { type PluginOptions } from '@defra/forms-engine-plugin/engine/types.js' import { formSubmissionService } from '@defra/forms-engine-plugin/services/index.js' +import { + type FormContext, + type FormRequestPayload, + type FormResponseToolkit, + type PluginOptions +} from '@defra/forms-engine-plugin/types' import { type FormDefinition } from '@defra/forms-model' import { Engine as CatboxMemory } from '@hapi/catbox-memory' import { Engine as CatboxRedis } from '@hapi/catbox-redis' @@ -129,7 +135,7 @@ export const configureEnginePlugin = async ({ const pluginObject = { plugin, options: { - cacheName: 'session', + cache: 'session', nunjucks: { baseLayoutPath: 'layout.html', paths @@ -137,7 +143,22 @@ export const configureEnginePlugin = async ({ model, services, viewContext: context, - baseUrl: config.get('baseUrl') + baseUrl: config.get('baseUrl'), + saveAndExit: ( + request: FormRequestPayload, + h: FormResponseToolkit, + _context: FormContext + ) => { + const { params } = request + const { slug } = params + const { isPreview, state } = checkFormStatus(params) + + return h.redirect( + !isPreview + ? `/save-and-exit/${slug}` + : `/save-and-exit/${slug}/${state}` + ) + } } } const routeOptions = { diff --git a/src/server/messaging/__stubs__/builder.js b/src/server/messaging/__stubs__/builder.js index 33606ecb9..6fbb18c72 100644 --- a/src/server/messaging/__stubs__/builder.js +++ b/src/server/messaging/__stubs__/builder.js @@ -16,16 +16,18 @@ export function buildSaveAndExitMessageData( partialSaveAndExitMessageData = {} ) { return { - formId: saveAndExitFormId, + form: { + id: 'formId', + title: 'My First Form', + isPreview: false, + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' + }, security: { question: SecurityQuestionsEnum.MemorablePlace, answer: 'a1' }, email: 'forms@example.com', - formStatus: { - status: FormStatus.Draft, - isPreview: false - }, state: {}, ...partialSaveAndExitMessageData } diff --git a/src/server/messaging/mappers/events.js b/src/server/messaging/mappers/events.js index b7a9e8af5..d225bc3de 100644 --- a/src/server/messaging/mappers/events.js +++ b/src/server/messaging/mappers/events.js @@ -1,25 +1,43 @@ import { + FormStatus, SubmissionEventMessageCategory, SubmissionEventMessageSchemaVersion, SubmissionEventMessageSource, SubmissionEventMessageType } from '@defra/forms-model' +import { config } from '~/src/config/index.js' + +const baseUrl = config.get('baseUrl') + /** * @param { string } formId + * @param { string } formTitle * @param { string } email * @param {{ question: SecurityQuestionsEnum, answer: string }} security - * @param {{ status: FormStatus, isPreview: boolean }} formStatus * @param { FormState } state + * @param { FormStatus } [status] * @returns {SaveAndExitMessage} */ -export function saveAndExitMapper(formId, email, security, formStatus, state) { +export function saveAndExitMapper( + formId, + formTitle, + email, + security, + state, + status +) { /** @type {SaveAndExitMessageData} */ const data = { - formId, + form: { + id: formId, + title: formTitle, + status: status ?? FormStatus.Live, + isPreview: !!status, + baseUrl + }, email, security, - formStatus, state } const now = new Date() @@ -35,6 +53,6 @@ export function saveAndExitMapper(formId, email, security, formStatus, state) { } /** - * @import { FormStatus, SaveAndExitMessage, SaveAndExitMessageData, SecurityQuestionsEnum } from '@defra/forms-model' + * @import { SaveAndExitMessage, SaveAndExitMessageData, SecurityQuestionsEnum } from '@defra/forms-model' * @import { FormState } from '@defra/forms-engine-plugin/engine/types.js' */ diff --git a/src/server/messaging/mappers/events.test.js b/src/server/messaging/mappers/events.test.js index d738a83f8..d1e92987b 100644 --- a/src/server/messaging/mappers/events.test.js +++ b/src/server/messaging/mappers/events.test.js @@ -12,29 +12,36 @@ import { saveAndExitMapper } from '~/src/server/messaging/mappers/events.js' describe('runner-events', () => { describe('saveAndExitMapper', () => { it('should map a payload into a SAVE_AND_EXIT event', () => { + /** + * @type {import('@defra/forms-model').SaveAndExitMessageData} + */ const payload = { - formId: 'formId', + form: { + id: 'formId', + title: 'My First Form', + isPreview: true, + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' + }, email: 'my-email@here.com', security: { question: SecurityQuestionsEnum.CharacterName, answer: 'brown' }, - formStatus: { - status: FormStatus.Draft, - isPreview: false - }, state: { formVal1: '123', formVal2: '456' } } + expect( saveAndExitMapper( - payload.formId, + payload.form.id, + payload.form.title, payload.email, payload.security, - payload.formStatus, - payload.state + payload.state, + payload.form.status ) ).toEqual({ schemaVersion: SubmissionEventMessageSchemaVersion.V1, @@ -44,16 +51,18 @@ describe('runner-events', () => { createdAt: expect.any(Date), messageCreatedAt: expect.any(Date), data: { - formId: payload.formId, + form: { + id: payload.form.id, + title: payload.form.title, + isPreview: payload.form.isPreview, + status: payload.form.status, + baseUrl: 'http://localhost:3009' + }, email: payload.email, security: { question: payload.security.question, answer: payload.security.answer }, - formStatus: { - status: payload.formStatus.status, - isPreview: payload.formStatus.isPreview - }, state: payload.state } }) diff --git a/src/server/messaging/publish-base.js b/src/server/messaging/publish-base.js index 6ce9e6fe9..45145bda1 100644 --- a/src/server/messaging/publish-base.js +++ b/src/server/messaging/publish-base.js @@ -23,7 +23,7 @@ export async function publishEvent(message) { const result = await client.send(command) logger.info( - `Published ${message.type} event for formId ${message.data.formId}. MessageId: ${result.MessageId}` + `Published ${message.type} event for formId ${message.data.form.id}. MessageId: ${result.MessageId}` ) return result diff --git a/src/server/messaging/publish.js b/src/server/messaging/publish.js index 7580ff8d0..6fcc89a1b 100644 --- a/src/server/messaging/publish.js +++ b/src/server/messaging/publish.js @@ -20,19 +20,28 @@ async function validateAndPublishEvent(saveAndExitMessage) { * Publish 'save and exit' event * The returned entityId will be a newly-generated guid * @param {string} formId + * @param {string} formTitle * @param {string} email * @param {{ question: SecurityQuestionsEnum, answer: string }} security - * @param {{ status: FormStatus, isPreview: boolean }} formStatus * @param {FormState} state + * @param {FormStatus} [status] */ export async function publishSaveAndExitEvent( formId, + formTitle, email, security, - formStatus, - state + state, + status ) { - const message = saveAndExitMapper(formId, email, security, formStatus, state) + const message = saveAndExitMapper( + formId, + formTitle, + email, + security, + state, + status + ) return validateAndPublishEvent(message) } diff --git a/src/server/messaging/publish.test.js b/src/server/messaging/publish.test.js index 36258a3b4..8f1b7e341 100644 --- a/src/server/messaging/publish.test.js +++ b/src/server/messaging/publish.test.js @@ -13,17 +13,22 @@ import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' jest.mock('~/src/server/messaging/publish-base.js') +/** + * @type {SaveAndExitMessageData} + */ const saveAndExitPayload = { - formId: 'formId', + form: { + id: 'formId', + title: 'My First Form', + isPreview: true, + status: FormStatus.Draft, + baseUrl: 'http://localhost:3009' + }, email: 'my-email@here.com', security: { question: SecurityQuestionsEnum.CharacterName, answer: 'brown' }, - formStatus: { - status: FormStatus.Draft, - isPreview: true - }, state: { formVal1: '123', formVal2: '456' @@ -45,11 +50,12 @@ describe('publish', () => { describe('publishSaveAndExitEvent', () => { it('should publish SAVE_AND_EXIT event', async () => { await publishSaveAndExitEvent( - saveAndExitPayload.formId, + saveAndExitPayload.form.id, + saveAndExitPayload.form.title, saveAndExitPayload.email, saveAndExitPayload.security, - saveAndExitPayload.formStatus, - saveAndExitPayload.state + saveAndExitPayload.state, + saveAndExitPayload.form.status ) expect(publishEvent).toHaveBeenCalledWith({ @@ -72,7 +78,7 @@ describe('publish', () => { publishSaveAndExitEvent(invalidPayload) ).rejects.toThrow( new ValidationError( - '"data.formId" must be a string. "data.email" is required. "data.state" is required', + '"data.form.id" must be a string. "data.form.title" is required. "data.email" is required. "data.state" is required', [], {} ) @@ -80,3 +86,7 @@ describe('publish', () => { }) }) }) + +/** + * @import { SaveAndExitMessageData } from '@defra/forms-model' + */ diff --git a/src/server/models/save-and-exit.js b/src/server/models/save-and-exit.js index 264ac5728..039bb7a3e 100644 --- a/src/server/models/save-and-exit.js +++ b/src/server/models/save-and-exit.js @@ -1,20 +1,30 @@ -import { SecurityQuestionsEnum } from '@defra/forms-model' +import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { SecurityQuestionsEnum, slugSchema } from '@defra/forms-model' import Joi from 'joi' -const pageTitle = 'Save your progress for later' +import { config } from '~/src/config/index.js' +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createJoiError } from '~/src/server/helpers/error-helper.js' + +const detailsPageTitle = 'Save your progress for later' +const confirmationPageTitle = 'Your progress has been saved' + +const MIN_PASSWORD_LENGTH = 3 +const MAX_PASSWORD_LENGTH = 40 // Field names/ids -const email = 'email' -const emailConfirmation = 'emailConfirmation' -const securityQuestion = 'securityQuestion' -const securityAnswer = 'securityAnswer' +const emailFieldName = 'email' +const emailConfirmationFieldName = 'emailConfirmation' +const securityQuestionFieldName = 'securityQuestion' +const securityAnswerFieldName = 'securityAnswer' const GOVUK_LABEL__M = 'govuk-label--m' +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') /** * @type { SecurityQuestion[]} */ -export const securityQuestions = [ +const securityQuestions = [ { text: 'What is a memorable place you have visited?', value: SecurityQuestionsEnum.MemorablePlace @@ -40,40 +50,40 @@ function buildErrors(err) { return {} } - const emailError = err.details.find((item) => item.path[0] === email) + const emailError = err.details.find((item) => item.path[0] === emailFieldName) const emailConfirmationError = err.details.find( - (item) => item.path[0] === emailConfirmation + (item) => item.path[0] === emailConfirmationFieldName ) const securityQuestionError = err.details.find( - (item) => item.path[0] === securityQuestion + (item) => item.path[0] === securityQuestionFieldName ) const securityAnswerError = err.details.find( - (item) => item.path[0] === securityAnswer + (item) => item.path[0] === securityAnswerFieldName ) const errors = [] if (emailError) { - errors.push({ text: emailError.message, href: `#${email}` }) + errors.push({ text: emailError.message, href: `#${emailFieldName}` }) } if (emailConfirmationError) { errors.push({ text: emailConfirmationError.message, - href: `#${emailConfirmation}` + href: `#${emailConfirmationFieldName}` }) } if (securityQuestionError) { errors.push({ text: securityQuestionError.message, - href: `#${securityQuestion}` + href: `#${securityQuestionFieldName}` }) } if (securityAnswerError) { errors.push({ text: securityAnswerError.message, - href: `#${securityAnswer}` + href: `#${securityAnswerFieldName}` }) } @@ -93,8 +103,8 @@ function buildErrors(err) { */ function buildEmailField(payload, error) { return { - id: email, - name: email, + id: emailFieldName, + name: emailFieldName, label: { text: 'Your email address', classes: GOVUK_LABEL__M, @@ -115,8 +125,8 @@ function buildEmailField(payload, error) { */ function buildEmailConfirmationField(payload, error) { return { - id: emailConfirmation, - name: emailConfirmation, + id: emailConfirmationFieldName, + name: emailConfirmationFieldName, label: { text: 'Confirm your email address', classes: GOVUK_LABEL__M, @@ -136,8 +146,8 @@ function buildEmailConfirmationField(payload, error) { */ function buildSecurityQuestionField(payload, error) { return { - id: securityQuestion, - name: securityQuestion, + id: securityQuestionFieldName, + name: securityQuestionFieldName, fieldset: { legend: { text: 'Choose a security question to answer', @@ -160,8 +170,8 @@ function buildSecurityQuestionField(payload, error) { */ function buildSecurityAnswerField(payload, error) { return { - id: securityAnswer, - name: securityAnswer, + id: securityAnswerFieldName, + name: securityAnswerFieldName, label: { text: 'Your answer to the security question', classes: GOVUK_LABEL__M @@ -173,23 +183,96 @@ function buildSecurityAnswerField(payload, error) { } } +export const securityAnswerSchema = Joi.string() + .min(MIN_PASSWORD_LENGTH) + .max(MAX_PASSWORD_LENGTH) + .required() + .messages({ + 'string.min': 'Your answer must be between 3 and 40 characters long', + 'string.max': 'Your answer must be between 3 and 40 characters long', + '*': 'Enter an answer to the security question' + }) + +/** + * Save and exit params schema + */ +export const paramsSchema = Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + +/** + * Save and exit form payload schema + */ +export const payloadSchema = Joi.object() + .keys({ + crumb: crumbSchema, + email: Joi.string().email().required().messages({ + 'string.email': + 'Enter an email address in the correct format, for example, hello@example.com', + '*': 'Enter an email address' + }), + emailConfirmation: Joi.string() + .valid(Joi.ref('email')) + .required() + .messages({ + '*': 'Your email address does not match. Check and try again.' + }), + securityQuestion: Joi.string() + .valid(...securityQuestions.map(({ value }) => value.toString())) + .required() + .messages({ + '*': 'Choose a security question to answer' + }), + securityAnswer: securityAnswerSchema + }) + .required() + +/** + * Save and exit resume params schema + */ +export const resumeParamsSchema = Joi.object() + .keys({ + formId: Joi.string().required(), + magicLinkId: Joi.string().uuid().required(), + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + +/** + * Save and exit validate payload schema + */ +export const validatePayloadSchema = Joi.object().keys({ + crumb: crumbSchema, + securityAnswer: securityAnswerSchema +}) + /** - * Get save and exit session flash key - * @param { string } state - the form state - * @param { string } formId - the form id + * Get save and exit session key + * @param {string} slug + * @param {FormStatus} [state] */ -export function getFlashKey(state, formId) { - return `${state}_${formId}_save_and_exit_email` +export function getKey(slug, state) { + return `save-and-exit-${slug}-${state ?? ''}` } /** - * The save and exit form view model - * @param {SaveAndExitParams} params + * The save and exit details form view model + * @param {FormMetadata} metadata + * @param {FormStatus} [status] * @param {SaveAndExitPayload} [payload] * @param {Error} [err] */ -export function saveAndExitViewModel(params, payload, err) { - const { state, slug } = params +export function detailsViewModel(metadata, status, payload, err) { + const { slug, title } = metadata + const formPath = constructFormUrl(slug, status) + + const backLink = { + href: formPath + } const { errors, @@ -201,16 +284,19 @@ export function saveAndExitViewModel(params, payload, err) { // Model fields const fields = { - [email]: buildEmailField(payload, emailError), - [emailConfirmation]: buildEmailConfirmationField( + [emailFieldName]: buildEmailField(payload, emailError), + [emailConfirmationFieldName]: buildEmailConfirmationField( payload, emailConfirmationError ), - [securityQuestion]: buildSecurityQuestionField( + [securityQuestionFieldName]: buildSecurityQuestionField( payload, securityQuestionError ), - [securityAnswer]: buildSecurityAnswerField(payload, securityAnswerError) + [securityAnswerFieldName]: buildSecurityAnswerField( + payload, + securityAnswerError + ) } // Model buttons @@ -220,17 +306,166 @@ export function saveAndExitViewModel(params, payload, err) { const cancelButton = { text: 'Cancel', classes: 'govuk-button--secondary', - href: `/${state}/${slug}` + href: formPath } return { - pageTitle, + name: title, + serviceUrl: formPath, + pageTitle: detailsPageTitle, + backLink, errors, fields, buttons: { continueButton, cancelButton } } } +/** + * The save and exit confirmation form view model + * @param {FormMetadata} metadata + * @param {string} email + * @param {FormStatus} [status] + */ +export function confirmationViewModel(metadata, email, status) { + const { slug, title } = metadata + const formPath = constructFormUrl(slug, status) + + return { + name: title, + serviceUrl: formPath, + pageTitle: confirmationPageTitle, + email, + saveAndExitExpiryDays + } +} + +/** + * The save and exit password form view model + * @param {string} formTitle + * @param {SecurityQuestionsEnum} securityQuestion - the security question + * @param {number} attemptsLeft + * @param {SaveAndExitResumePasswordPayload} [payload] + * @param {Error} [err] + */ +export function passwordViewModel( + formTitle, + securityQuestion, + attemptsLeft, + payload, + err +) { + const pageTitle = 'Continue with your form' + const { errors, securityAnswerError } = buildErrors(err) + + // Model fields + const fields = { + [securityAnswerFieldName]: { + id: securityAnswerFieldName, + name: securityAnswerFieldName, + label: { + text: securityQuestions.find((x) => x.value === securityQuestion)?.text, + classes: GOVUK_LABEL__M + }, + value: payload?.securityAnswer ?? '', + errorMessage: securityAnswerError && { + text: securityAnswerError.message + } + } + } + + // Model buttons + const continueButton = { + text: 'Continue' + } + + return { + name: formTitle, + pageTitle, + errors, + fields, + attemptsLeft, + buttons: { continueButton } + } +} + +/** + * The save and exit error form view model + * @param {{ slug: string }} payload + */ +export function resumeErrorViewModel(payload) { + const pageTitle = 'You cannot resume your form' + + // Model buttons + const continueButton = { + text: 'Start form again', + href: `/form/${payload.slug}` + } + + return { + pageTitle, + buttons: payload.slug ? { continueButton } : {} + } +} + +/** + * @param {number} attemptsRemaining + */ +export function createInvalidPasswordError(attemptsRemaining) { + return createJoiError( + securityAnswerFieldName, + `Your answer is incorrect. You have ${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} remaining.` + ) +} + +/** + * The save and exit form view model when user is locked out + * @param {FormMetadata} form + * @param {SaveAndExitResumeDetails} validatedLink + * @param {number} maxPasswordAttempts + */ +export function lockedOutViewModel(form, validatedLink, maxPasswordAttempts) { + return { + name: form.title, + maxPasswordAttempts, + buttons: { + continueButton: { + text: 'Start form again', + href: constructFormUrl(form.slug, validatedLink.form.status) + } + } + } +} + +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +export function constructFormUrl(slug, status) { + if (!status) { + return `${FORM_PREFIX}/${slug}` + } + + return `${FORM_PREFIX}/preview/${status}/${slug}` +} + +/** + * The save and exit success form view model + * @param {FormMetadata} form + * @param {FormStatus} [status] + */ +export function resumeSuccessViewModel(form, status) { + // Model buttons + const continueButton = { + text: 'Resume form', + href: constructFormUrl(form.slug, status) + } + + return { + name: form.title, + buttons: { continueButton } + } +} + /** * @typedef {object} SecurityQuestion * @property {string} text - the question text @@ -239,14 +474,39 @@ export function saveAndExitViewModel(params, payload, err) { /** * @typedef {object} SaveAndExitParams - * @property {string} state - the preview/live state * @property {string} slug - the form slug + * @property {FormStatus} [state] - the form status (draft/live) when in preview mode */ /** * @typedef {object} SaveAndExitPayload * @property {string} email - email * @property {string} emailConfirmation - email confirmation - * @property {string} securityQuestion - the security question + * @property {SecurityQuestionsEnum} securityQuestion - the security question * @property {string} securityAnswer - the security answer */ + +/** + * @typedef {object} SaveAndExitResumeParams + * @property {string} slug - the form slug + * @property {string} magicLinkId - the link parameter provided in the magic link + */ + +/** + * @typedef {object} SaveAndExitResumePasswordParams + * @property {string} formId - the form id answer + * @property {string} magicLinkId - the magic link id + * @property {string} slug - the form slug + * @property {FormStatus} [state] - the form status + */ + +/** + * @typedef {object} SaveAndExitResumePasswordPayload + * @property {string} securityAnswer - the security answer + */ + +/** + * @import { FormMetadata } from '@defra/forms-model' + * @import { FormStatus } from '@defra/forms-engine-plugin/types' + * @import { SaveAndExitResumeDetails } from '~/src/server/types.js' + */ diff --git a/src/server/plugins/error-preview/error-preview.js b/src/server/plugins/error-preview/error-preview.js index 496a05d28..3ca1652ea 100644 --- a/src/server/plugins/error-preview/error-preview.js +++ b/src/server/plugins/error-preview/error-preview.js @@ -1,7 +1,7 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import Boom from '@hapi/boom' import { createErrorPreviewModel } from '~/src/server/plugins/error-preview/error-preview-helper.js' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, getFormMetadata @@ -34,5 +34,5 @@ export async function getErrorPreviewHandler(request, h) { /** * @import { ResponseToolkit } from '@hapi/hapi' - * @import { FormRequest } from '~/src/server/routes/types.js' + * @import { FormRequest } from '@defra/forms-engine-plugin/engine/types/index.js' */ diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index c5c61f345..629ec8e04 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -20,7 +20,7 @@ const logger = createLogger() let webpackManifest /** - * @param {FormRequest | FormRequestPayload | null} request + * @param {AnyFormRequest | null} request */ export function context(request) { const manifestPath = join(config.get('publicDir'), 'assets-manifest.json') @@ -85,5 +85,5 @@ export function context(request) { /** * @import { ViewContext } from '~/src/server/plugins/nunjucks/types.js' - * @import { FormRequest, FormRequestPayload } from '~/src/server/routes/types.js' + * @import { AnyFormRequest } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/plugins/nunjucks/context.test.js b/src/server/plugins/nunjucks/context.test.js index ad8350fe3..46e7c9211 100644 --- a/src/server/plugins/nunjucks/context.test.js +++ b/src/server/plugins/nunjucks/context.test.js @@ -139,5 +139,5 @@ describe('Nunjucks context', () => { }) /** - * @import { FormRequest } from '~/src/server/routes/types.js' + * @import { FormRequest } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 661568b0d..7e932f178 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -1,13 +1,14 @@ import { - getCacheService, handleLegacyRedirect, isPathRelative } from '@defra/forms-engine-plugin/engine/helpers.js' import { - slugSchema, - type FormStatus, - type SecurityQuestionsEnum -} from '@defra/forms-model' + crumbSchema, + itemIdSchema, + pathSchema, + stateSchema +} from '@defra/forms-engine-plugin/schema.js' +import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' import { type Request, @@ -26,32 +27,23 @@ import { import { type CookieConsent } from '~/src/common/types.js' import { config } from '~/src/config/index.js' import { FORM_PREFIX } from '~/src/server/constants.js' -import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' -import { - getFlashKey, - saveAndExitViewModel, - securityQuestions, - type SaveAndExitParams, - type SaveAndExitPayload -} from '~/src/server/models/save-and-exit.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' -import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' import { - crumbSchema, - itemIdSchema, - pathSchema, - stateSchema -} from '~/src/server/schemas/index.js' + healthRoute, + publicRoutes, + saveAndExitRoutes +} from '~/src/server/routes/index.js' import { getFormMetadata } from '~/src/server/services/formsService.js' const routes: ServerRoute[] = [...publicRoutes, healthRoute] +const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays') export default { plugin: { name: 'router', register: (server) => { server.route(routes) + server.route(saveAndExitRoutes as ServerRoute[]) // /preview/{state}/{slug} -> {FORM_PREFIX}/preview/{state}/{slug} server.route({ @@ -137,7 +129,7 @@ export default { const { slug } = request.params const form = await getFormMetadata(slug) - return h.view('help/privacy-notice', { form }) + return h.view('help/privacy-notice', { form, saveAndExitExpiryDays }) }, options }) @@ -293,159 +285,6 @@ export default { } } }) - - server.route<{ - Params: SaveAndExitParams - Payload: SaveAndExitPayload - }>({ - method: 'GET', - path: '/save-and-exit/{state}/{slug}', - handler(request, h) { - const { params, payload } = request - const model = saveAndExitViewModel(params, payload) - - return h.view('save-and-exit-details', model) - }, - options: { - validate: { - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required() - } - } - }) - - server.route<{ - Params: SaveAndExitParams - Payload: SaveAndExitPayload - }>({ - method: 'POST', - path: '/save-and-exit/{state}/{slug}', - async handler(request, h) { - const { params, payload } = request - const { state, slug } = params - const { email, securityQuestion, securityAnswer } = payload - const metadata = await getFormMetadata(slug) - - const cacheService = getCacheService(request.server) - - // Publish topic message - const formStatus = { - status: state as FormStatus, - isPreview: false - } - const security = { - question: securityQuestion as SecurityQuestionsEnum, - answer: securityAnswer - } - - await publishSaveAndExitEvent( - metadata.id, - email, - security, - formStatus, - await cacheService.getState( - request as unknown as FormRequestPayload - ) - ) - - // Clear all form data - await cacheService.clearState( - request as unknown as FormRequestPayload - ) - - // Flash the email over to the confirmation page - const key = getFlashKey(state, metadata.id) - request.yar.flash(key, email) - - // Redirect to the save and exit confirmation page - return h.redirect(`/save-and-exit/${state}/${slug}/confirmation`) - }, - options: { - validate: { - failAction: (request, h, err) => { - const { params, payload } = request - const model = saveAndExitViewModel( - params as SaveAndExitParams, - payload as SaveAndExitPayload, - err - ) - - return h.view('save-and-exit-details', model).takeover() - }, - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required(), - payload: Joi.object() - .keys({ - crumb: crumbSchema, - email: Joi.string().email().required().messages({ - 'string.empty': 'Enter an email address', - 'string.email': 'Enter an email address in the correct format' - }), - emailConfirmation: Joi.string() - .valid(Joi.ref('email')) - .required() - .messages({ - 'any.only': - 'Your email address does not match. Check and try again.' - }), - securityQuestion: Joi.string() - .valid( - ...securityQuestions.map(({ value }) => value.toString()) - ) - .required() - .messages({ - '*': 'Choose a security question' - }), - securityAnswer: Joi.string().required().messages({ - '*': 'Enter a security answer' - }) - }) - .required() - } - } - }) - - server.route<{ - Params: SaveAndExitParams - }>({ - method: 'GET', - path: '/save-and-exit/{state}/{slug}/confirmation', - async handler(request, h) { - const { params } = request - const { state, slug } = params - const metadata = await getFormMetadata(slug) - - // Get the flashed email - const key = getFlashKey(state, metadata.id) - const messages = request.yar.flash(key) - - if (messages.length === 0) { - return Boom.badRequest('No email found in flash cache') - } - - const email = messages[0] - - return h.view('save-and-exit-confirmation', { email }) - }, - options: { - validate: { - params: Joi.object() - .keys({ - state: stateSchema, - slug: slugSchema - }) - .required() - } - } - }) } } } satisfies ServerRegisterPluginObject diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 188dbaca0..356adf642 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,2 +1,3 @@ export { default as publicRoutes } from '~/src/server/routes/public.js' export { default as healthRoute } from '~/src/server/routes/health.js' +export { default as saveAndExitRoutes } from '~/src/server/routes/save-and-exit.js' diff --git a/src/server/routes/save-and-exit-with-cache.test.js b/src/server/routes/save-and-exit-with-cache.test.js new file mode 100644 index 000000000..f45054392 --- /dev/null +++ b/src/server/routes/save-and-exit-with-cache.test.js @@ -0,0 +1,112 @@ +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { StatusCodes } from 'http-status-codes' + +import { createServer } from '~/src/server/index.js' +import { + getFormMetadataById, + validateSaveAndExitCredentials +} from '~/src/server/services/formsService.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/helpers/error-helper.js') +jest.mock('@defra/forms-engine-plugin/engine/helpers.js') + +describe('Save-and-exit check routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer() + await server.initialize() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' + const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' + + describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { + test('/route handles valid password with no supplied form status', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: true, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + setState: jest.fn() + })) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'valid' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/resume-form-success/my-form-to-resume` + ) + }) + + test('/route handles valid password with draft preview', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: true, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID, + status: 'draft', + isPreview: true + } + }) + // @ts-expect-error - not all method mocked + jest.mocked(getCacheService).mockImplementationOnce(() => ({ + setState: jest.fn() + })) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'valid' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/resume-form-success/my-form-to-resume/draft` + ) + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js new file mode 100644 index 000000000..ba9954319 --- /dev/null +++ b/src/server/routes/save-and-exit.js @@ -0,0 +1,408 @@ +import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' +import { stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { slugSchema } from '@defra/forms-model' +import Boom from '@hapi/boom' +import { StatusCodes } from 'http-status-codes' +import Joi from 'joi' + +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' +import { + confirmationViewModel, + createInvalidPasswordError, + detailsViewModel, + getKey, + lockedOutViewModel, + paramsSchema, + passwordViewModel, + payloadSchema, + resumeErrorViewModel, + resumeParamsSchema, + resumeSuccessViewModel, + validatePayloadSchema +} from '~/src/server/models/save-and-exit.js' +import { + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails, + validateSaveAndExitCredentials +} from '~/src/server/services/formsService.js' +const logger = createLogger() + +const maxInvalidPasswordAttempts = 5 + +const ERROR_BASE_URL = '/resume-form-error' + +// View paths +const RESUME_ERROR = 'save-and-exit/resume-error' +const RESUME_ERROR_LOCKED = 'save-and-exit/resume-error-locked' +const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password' +const RESUME_SUCCESS = 'save-and-exit/resume-success' + +/** + * @param {number} attemptsSoFar + */ +export function getPasswordAttemptsLeft(attemptsSoFar) { + return maxInvalidPasswordAttempts - attemptsSoFar +} + +export default [ + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} + */ + ({ + method: 'GET', + path: '/save-and-exit/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + const model = detailsViewModel(metadata, status) + + return h.view('save-and-exit-details', model) + }, + options: { + validate: { + params: paramsSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams, Payload: SaveAndExitPayload }>} + */ + ({ + method: 'POST', + path: '/save-and-exit/{slug}/{state?}', + async handler(request, h) { + const { params, payload } = request + const { slug, state: status } = params + const { email, securityQuestion, securityAnswer } = payload + const metadata = await getFormMetadata(slug) + const cacheService = getCacheService(request.server) + + // Publish topic message + const security = { + question: securityQuestion, + answer: securityAnswer + } + const state = await cacheService.getState(request) + + await publishSaveAndExitEvent( + metadata.id, + metadata.title, + email, + security, + state, + status + ) + + // Clear all form data + await cacheService.clearState(request) + + // Flash the email over to the confirmation page + request.yar.flash(getKey(slug, status), email) + + // Redirect to the save and exit confirmation page + const statusPath = status ? `/${status}` : '' + + return h.redirect(`/save-and-exit/${slug}/confirmation${statusPath}`) + }, + options: { + validate: { + async failAction(request, h, err) { + const { params, payload } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + const model = detailsViewModel( + metadata, + status, + /** @type {SaveAndExitPayload} */ (payload), + err + ) + + return h.view('save-and-exit-details', model).takeover() + }, + params: paramsSchema, + payload: payloadSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitParams }>} + */ + ({ + method: 'GET', + path: '/save-and-exit/{slug}/confirmation/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state: status } = params + const metadata = await getFormMetadata(slug) + + // Get the flashed email + const messages = request.yar.flash(getKey(slug, status)) + + if (messages.length === 0) { + return Boom.badRequest('No email found in flash cache') + } + + const email = messages[0] + const model = confirmationViewModel(metadata, email, status) + + return h.view('save-and-exit-confirmation', model) + }, + options: { + validate: { + params: paramsSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { formId: string, magicLinkId: string } }>} + */ + ({ + method: 'GET', + path: '/resume-form/{formId}/{magicLinkId}', + async handler(request, h) { + const { params } = request + const { formId, magicLinkId } = params + + // Check form id + let form + try { + form = await getFormMetadataById(formId) + } catch (err) { + logger.error( + err, + `Invalid formId ${formId} in magic link id ${magicLinkId}` + ) + return h.redirect(ERROR_BASE_URL).code(StatusCodes.SEE_OTHER) + } + + // Check magic link id + let linkDetails + try { + linkDetails = await getSaveAndExitDetails(magicLinkId) + + if (!linkDetails) { + throw new Error('No link found') + } + } catch (err) { + logger.error( + err, + `Invalid magic link id ${magicLinkId} with form id ${formId}` + ) + } + + if (!linkDetails || form.id !== linkDetails.form.id) { + return h + .redirect(`${ERROR_BASE_URL}/${form.slug}`) + .code(StatusCodes.SEE_OTHER) + } + + const { isPreview, status } = linkDetails.form + + const slugAndState = isPreview ? `/${status}` : '' + + return h.redirect( + `/resume-form-verify/${formId}/${magicLinkId}/${form.slug}${slugAndState}` + ) + }, + options: { + validate: { + params: Joi.object() + .keys({ + formId: Joi.string().required(), + magicLinkId: Joi.string().uuid().required() + }) + .required() + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: SaveAndExitResumePasswordParams }>} + */ + ({ + method: 'GET', + path: '/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { formId, magicLinkId } = params + const resumeDetails = await getSaveAndExitDetails(magicLinkId) + + if (!resumeDetails) { + return h.redirect(ERROR_BASE_URL) + } + + // Check form id + let form + try { + form = await getFormMetadataById(resumeDetails.form.id) + } catch (err) { + logger.error( + err, + `Invalid formId ${formId} in magic link id ${magicLinkId}` + ) + return h.redirect(ERROR_BASE_URL) + } + + const model = passwordViewModel( + form.title, + resumeDetails.question, + getPasswordAttemptsLeft(resumeDetails.invalidPasswordAttempts) + ) + + return h.view(RESUME_PASSWORD_PATH, model) + }, + options: { + validate: { + params: resumeParamsSchema + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { slug: string } }>} + */ + ({ + method: 'GET', + path: '/resume-form-error/{slug?}', + handler(request, h) { + const { params } = request + const { slug } = params + const model = resumeErrorViewModel({ slug }) + + return h.view(RESUME_ERROR, model) + }, + options: { + validate: { + params: Joi.object() + .keys({ + slug: slugSchema.optional() + }) + .required() + } + } + }), + /** + * @satisfies {ServerRoute<{ Payload: SaveAndExitResumePasswordPayload, Params: SaveAndExitResumePasswordParams }>} + */ + ({ + method: 'POST', + path: '/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', + async handler(request, h) { + const { params, payload } = request + const { formId, magicLinkId } = params + const { securityAnswer } = payload + + // Validate the security answer + const validatedLink = await validateSaveAndExitCredentials( + magicLinkId, + securityAnswer + ) + + // Reload form title in case it has changed + const form = await getFormMetadataById(formId) + + if (validatedLink.validPassword) { + // Restore state + const cacheService = getCacheService(request.server) + await cacheService.setState(request, validatedLink.state) + + const { isPreview, status } = validatedLink.form + + const slugAndState = isPreview ? `/${status}` : '' + + return h.redirect(`/resume-form-success/${form.slug}${slugAndState}`) + } + + const attemptsRemaining = getPasswordAttemptsLeft( + validatedLink.invalidPasswordAttempts + ) + if (attemptsRemaining > 0) { + // User has more password attempts left + logger.info( + `Invalid password attempt for form id ${validatedLink.form.id}` + ) + const error = createInvalidPasswordError(attemptsRemaining) + + const model = passwordViewModel( + form.title, + validatedLink.question, + attemptsRemaining, + undefined, + error + ) + + return h.view(RESUME_PASSWORD_PATH, model) + } else { + // Locked out + const model = lockedOutViewModel( + form, + validatedLink, + maxInvalidPasswordAttempts + ) + return h.view(RESUME_ERROR_LOCKED, model) + } + }, + options: { + validate: { + params: resumeParamsSchema, + payload: validatePayloadSchema, + failAction: async (request, h, error) => { + const params = /** @type {SaveAndExitResumePasswordParams} */ ( + request.params + ) + const payload = /** @type {SaveAndExitResumePasswordPayload} */ ( + request.payload + ) + const resumeDetails = await getSaveAndExitDetails(params.magicLinkId) + + if (!resumeDetails) { + return h.redirect(ERROR_BASE_URL).takeover() + } + + const form = await getFormMetadataById(resumeDetails.form.id) + + const model = passwordViewModel( + form.title, + resumeDetails.question, + getPasswordAttemptsLeft(resumeDetails.invalidPasswordAttempts), + payload, + error + ) + + return h.view(RESUME_PASSWORD_PATH, model).takeover() + } + } + } + }), + /** + * @satisfies {ServerRoute<{ Params: { slug: string, state?: string} }>} + */ + ({ + method: 'GET', + path: '/resume-form-success/{slug}/{state?}', + async handler(request, h) { + const { params } = request + const { slug, state } = params + const form = await getFormMetadata(slug) + const model = resumeSuccessViewModel(form, state) + + return h.view(RESUME_SUCCESS, model) + }, + options: { + validate: { + params: Joi.object() + .keys({ + slug: slugSchema, + state: stateSchema.optional() + }) + .required() + } + } + }) +] + +/** + * @import { ServerRoute } from '@hapi/hapi' + * @import { SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' + */ diff --git a/src/server/routes/save-and-exit.test.js b/src/server/routes/save-and-exit.test.js new file mode 100644 index 000000000..65438f360 --- /dev/null +++ b/src/server/routes/save-and-exit.test.js @@ -0,0 +1,462 @@ +import { StatusCodes } from 'http-status-codes' + +import { createJoiError } from '~/src/server/helpers/error-helper.js' +import { createServer } from '~/src/server/index.js' +import { + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails, + validateSaveAndExitCredentials +} from '~/src/server/services/formsService.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/services/formsService.js') +jest.mock('~/src/server/helpers/error-helper.js') + +describe('Save-and-exit check routes', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer() + await server.initialize() + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + const FORM_ID = 'eab6ac6c-79b6-439f-bd94-d93eb121b3f1' + const MAGIC_LINK_ID = 'fd4e6453-fb32-43e4-b4cf-12b381a713de' + + describe('GET /resume-form/{formId}/{magicLinkId}', () => { + test('/route forwards correctly on success', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe( + `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + ) + }) + + test('/route forwards correctly on invalid form error', async () => { + jest.mocked(getFormMetadataById).mockImplementationOnce(() => { + throw new Error('form not found') + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/resume-form-error') + }) + + test('/route forwards correctly on magic link error', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(() => { + throw new Error('magic link not found') + }) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/resume-form-error/my-form-to-resume' + ) + }) + + test('/route forwards correctly on magic link error 2', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce(undefined) + + const options = { + method: 'GET', + url: `/resume-form/${FORM_ID}/${MAGIC_LINK_ID}` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe( + '/resume-form-error/my-form-to-resume' + ) + }) + }) + + describe('GET /resume-form-verify/{formId}/{magicLinkId}/{slug}/state?}', () => { + test('/route renders page', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + }) + + test('/route forwards correctly on invalid form error', async () => { + jest.mocked(getFormMetadataById).mockImplementationOnce(() => { + throw new Error('form not found') + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'GET', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/resume-form-error') + }) + + test('/route forwards correctly on magic link error', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ slug: 'my-form-to-resume' }) + // @ts-expect-error - allow partial objects for tests + jest.mocked(getSaveAndExitDetails).mockImplementationOnce(undefined) + + const options = { + method: 'GET', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume/draft` + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/resume-form-error') + }) + }) + + describe('GET /resume-form-error', () => { + test('/route renders page without slug', async () => { + const options = { + method: 'GET', + url: '/resume-form-error' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + + const $button = container.queryByRole('button', { + name: 'Start form again' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).not.toBeInTheDocument() + }) + + test('/route renders page with slug', async () => { + const options = { + method: 'GET', + url: '/resume-form-error/my-slug' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + + const $button = container.queryByRole('button', { + name: 'Start form again' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute('href', '/form/my-slug') + }) + }) + + describe('GET /resume-form-success', () => { + test('/route renders page without state', async () => { + jest + .mocked(getFormMetadata) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + + const options = { + method: 'GET', + url: '/resume-form-success/my-slug' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Welcome back to your form') + + const $button = container.queryByRole('button', { + name: 'Resume form' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute('href', '/form/my-form-to-resume') + }) + + test('/route renders page with slug', async () => { + jest + .mocked(getFormMetadata) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + + const options = { + method: 'GET', + url: '/resume-form-success/my-slug/draft' + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Welcome back to your form') + + const $button = container.queryByRole('button', { + name: 'Resume form' + }) + + expect($mastheadHeading).toBeInTheDocument() + expect($button).toBeInTheDocument() + expect($button).toHaveAttribute( + 'href', + '/form/preview/draft/my-form-to-resume' + ) + }) + }) + + describe('/resume-form-verify/{formId}/{magicLinkId}/{slug}/{state?}', () => { + test('/route handles invalid password', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'invalid' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + expect(createJoiError).toHaveBeenCalledWith( + 'securityAnswer', + 'Your answer is incorrect. You have 4 attempts remaining.' + ) + }) + + test('/route handles lockout', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 5, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: 'invalid' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText( + 'You cannot resume your form' + ) + expect($mastheadHeading).toBeInTheDocument() + const $errorMessage = container.getByText( + 'The answer to your security question was incorrect 5 times. You have run out of attempts to resume your form.' + ) + expect($errorMessage).toBeInTheDocument() + }) + + test('/route handles missing password', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce({ + // @ts-expect-error - allow partial objects for tests + form: { + isPreview: true, + status: 'draft' + } + }) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: '' + } + } + + const { response, container } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.OK) + + const $mastheadHeading = container.getByText('Continue with your form') + expect($mastheadHeading).toBeInTheDocument() + expect(createJoiError).not.toHaveBeenCalled() + }) + + test('/route handles missing password and invalid url', async () => { + jest + .mocked(getFormMetadataById) + // @ts-expect-error - allow partial objects for tests + .mockResolvedValueOnce({ + slug: 'my-form-to-resume', + title: 'My Form To Resume' + }) + jest.mocked(validateSaveAndExitCredentials).mockResolvedValueOnce({ + validPassword: false, + invalidPasswordAttempts: 1, + // @ts-expect-error - allow partial objects for tests + form: { + id: FORM_ID + } + }) + jest.mocked(getSaveAndExitDetails).mockResolvedValueOnce(undefined) + + const options = { + method: 'POST', + url: `/resume-form-verify/${FORM_ID}/${MAGIC_LINK_ID}/my-form-to-resume`, + payload: { + securityAnswer: '' + } + } + + const { response } = await renderResponse(server, options) + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) + expect(response.headers.location).toBe('/resume-form-error') + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts deleted file mode 100644 index 12a0a5c47..000000000 --- a/src/server/routes/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type FormPayload } from '@defra/forms-engine-plugin/engine/types.js' -import { type ReqRefDefaults, type Request } from '@hapi/hapi' - -export interface FormQuery extends Partial> { - /** - * Allow preview URL direct access without relevant page checks - */ - force?: string - - /** - * Redirect location after 'continue' form action - */ - returnUrl?: string -} - -export interface FormParams extends Partial> { - path: string - slug: string - state?: FormStatus -} - -export interface FormRequestRefs - extends Omit { - Params: FormParams - Payload: object | undefined - Query: FormQuery -} - -export interface FormRequestPayloadRefs extends FormRequestRefs { - Payload: FormPayload -} - -export type FormRequest = Request -export type FormRequestPayload = Request - -export enum FormAction { - Continue = 'continue', - Validate = 'validate', - Delete = 'delete', - AddAnother = 'add-another', - Send = 'send' -} - -export enum FormStatus { - Draft = 'draft', - Live = 'live' -} diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts deleted file mode 100644 index 8cd4a61b3..000000000 --- a/src/server/schemas/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type FormPayloadParams } from '@defra/forms-engine-plugin/engine/types.js' -import Joi from 'joi' - -import { FormAction, FormStatus } from '~/src/server/routes/types.js' - -export const stateSchema = Joi.string() - .valid(FormStatus.Draft, FormStatus.Live) - .required() - -export const actionSchema = Joi.string() - .valid( - FormAction.Continue, - FormAction.Validate, - FormAction.Delete, - FormAction.AddAnother, - FormAction.Send - ) - .default(FormAction.Validate) - .optional() - -export const pathSchema = Joi.string().required() -export const itemIdSchema = Joi.string().uuid().required() -export const crumbSchema = Joi.string().optional().allow('') -export const confirmSchema = Joi.boolean().empty(false) - -export const paramsSchema = Joi.object() - .keys({ - action: actionSchema, - confirm: confirmSchema, - crumb: crumbSchema, - itemId: itemIdSchema.optional() - }) - .default({}) - .optional() diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 3e0ac0f1f..149a368a6 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -1,18 +1,42 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { formMetadataSchema } from '@defra/forms-model' import { config } from '~/src/config/index.js' -import { FormStatus } from '~/src/server/routes/types.js' -import { getJson } from '~/src/server/services/httpService.js' +import { getJson, postJson } from '~/src/server/services/httpService.js' + +const managerUrl = config.get('managerUrl') +const submissionUrl = config.get('submissionUrl') /** - * Retrieves a form definition from the form manager for a given slug + * Retrieves a form metadata from the form manager for a given slug * @param {string} slug - the slug of the form */ export async function getFormMetadata(slug) { const getJsonByType = /** @type {typeof getJson} */ (getJson) const { payload: metadata } = await getJsonByType( - `${config.get('managerUrl')}/forms/slug/${slug}` + `${managerUrl}/forms/slug/${slug}` + ) + + // Run it through the schema to coerce dates + const result = formMetadataSchema.validate(metadata) + + if (result.error) { + throw result.error + } + + return result.value +} + +/** + * Retrieves a form metadata from the form manager for a given form id + * @param {string} formId - the slug of the form + */ +export async function getFormMetadataById(formId) { + const getJsonByType = /** @type {typeof getJson} */ (getJson) + + const { payload: metadata } = await getJsonByType( + `${managerUrl}/forms/${formId}` ) // Run it through the schema to coerce dates @@ -35,12 +59,59 @@ export async function getFormDefinition(id, state) { const suffix = state === FormStatus.Draft ? `/${state}` : '' const { payload: definition } = await getJsonByType( - `${config.get('managerUrl')}/forms/${id}/definition${suffix}` + `${managerUrl}/forms/${id}/definition${suffix}` ) return definition } +/** + * Retrieves a save-and-exit record from the form submission api for a given magic link + * @param {string} magicLinkId - the id of the magic link + */ +export async function getSaveAndExitDetails(magicLinkId) { + const getJsonByType = /** @type {typeof getJson} */ ( + getJson + ) + + const { payload: results } = await getJsonByType( + `${submissionUrl}/save-and-exit/${magicLinkId}` + ) + + return results +} + +/** + * Validates correct password for a save-and-exit record from the form submission api for a given magic link + * @param {string} magicLinkId - the id of the magic link + * @param {string} securityAnswer - the security answer provided by the user + */ +export async function validateSaveAndExitCredentials( + magicLinkId, + securityAnswer +) { + const postJsonByType = + /** @type {typeof postJson} */ (postJson) + + const { payload: results } = await postJsonByType( + `${submissionUrl}/save-and-exit/${magicLinkId}`, + { + payload: { + securityAnswer + } + } + ) + + if (!results) { + throw new Error( + 'Unexpected empty response in validateSaveAndExitCredentials' + ) + } + + return results +} + /** * @import { FormDefinition, FormMetadata } from '@defra/forms-model' + * @import { SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' */ diff --git a/src/server/services/formsService.test.js b/src/server/services/formsService.test.js index 2008f6164..8999acefc 100644 --- a/src/server/services/formsService.test.js +++ b/src/server/services/formsService.test.js @@ -1,14 +1,19 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { StatusCodes } from 'http-status-codes' -import { FormStatus } from '~/src/server/routes/types.js' import { getFormDefinition, - getFormMetadata + getFormMetadata, + getFormMetadataById, + getSaveAndExitDetails, + validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' -import { getJson } from '~/src/server/services/httpService.js' +import { getJson, postJson } from '~/src/server/services/httpService.js' import * as fixtures from '~/test/fixtures/index.js' -const { MANAGER_URL } = process.env +const { MANAGER_URL, SUBMISSION_URL } = process.env + +const magicLinkId = '7ac201b2-bea3-490d-8ccb-2734b2794f7b' jest.mock('~/src/server/services/httpService') @@ -57,6 +62,48 @@ describe('Forms service', () => { }) }) + describe('getFormMetadataById', () => { + beforeEach(() => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: metadata + }) + }) + + it('requests JSON via form slug', async () => { + await getFormMetadataById(metadata.id) + + expect(getJson).toHaveBeenCalledWith( + `${MANAGER_URL}/forms/${metadata.id}` + ) + }) + + it('coerces timestamps from string to Date', async () => { + const payload = { + ...structuredClone(metadata), + + // JSON payload uses string dates in transit + createdAt: metadata.createdAt.toISOString(), + updatedAt: metadata.updatedAt.toISOString() + } + + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload + }) + + await expect(getFormMetadataById(metadata.id)).resolves.toEqual({ + ...metadata, + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + }) + }) + describe('getFormDefinition', () => { beforeEach(() => { jest.mocked(getJson).mockResolvedValue({ @@ -83,6 +130,45 @@ describe('Forms service', () => { ) }) }) + + describe('getSaveAndExitDetails', () => { + beforeEach(() => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: definition + }) + }) + + it('requests JSON via form ID (draft)', async () => { + await getSaveAndExitDetails(magicLinkId) + + expect(getJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/save-and-exit/${magicLinkId}` + ) + }) + }) + + describe('validateSaveAndExitCredentials', () => { + beforeEach(() => { + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: definition + }) + }) + + it('requests JSON via form ID (draft)', async () => { + await validateSaveAndExitCredentials(magicLinkId, 'answer') + + expect(postJson).toHaveBeenCalledWith( + `${SUBMISSION_URL}/save-and-exit/${magicLinkId}`, + { payload: { securityAnswer: 'answer' } } + ) + }) + }) }) /** diff --git a/src/server/services/outputService.test.js b/src/server/services/outputService.test.js index 5d12efefb..d8caf9191 100644 --- a/src/server/services/outputService.test.js +++ b/src/server/services/outputService.test.js @@ -1,8 +1,8 @@ import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { getFormatter } from '@defra/forms-engine-plugin/engine/outputFormatters/index.js' +import { FormStatus } from '@defra/forms-engine-plugin/types' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' -import { FormStatus } from '~/src/server/routes/types.js' import { OutputService, createOutputService @@ -504,5 +504,5 @@ describe('OutputService', () => { * @import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' * @import { DetailItem } from '@defra/forms-engine-plugin/engine/models/types.js' * @import { SubmitResponsePayload, FormMetadata } from '@defra/forms-model' - * @import { FormRequestPayload } from '~/src/server/routes/types.js' + * @import { FormRequestPayload } from '@defra/forms-engine-plugin/types' */ diff --git a/src/server/services/outputService.ts b/src/server/services/outputService.ts index 3e51a7114..69350226b 100644 --- a/src/server/services/outputService.ts +++ b/src/server/services/outputService.ts @@ -6,7 +6,10 @@ import { type FormAdapterSubmissionMessagePayload, type FormContext } from '@defra/forms-engine-plugin/engine/types.js' -import { type OutputService as IOutputService } from '@defra/forms-engine-plugin/types' +import { + type FormRequestPayload, + type OutputService as IOutputService +} from '@defra/forms-engine-plugin/types' import { type FormMetadata, type SubmitResponsePayload @@ -14,7 +17,6 @@ import { import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { publishFormAdapterEvent } from '~/src/server/messaging/formAdapterEventPublisher.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' const logger = createLogger() diff --git a/src/server/types.ts b/src/server/types.ts index 4fc49b4c9..2569304f0 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,18 +1,18 @@ import { type FormModel } from '@defra/forms-engine-plugin/engine/models/index.js' import { type DetailItem } from '@defra/forms-engine-plugin/engine/models/types.js' import { type FormContext } from '@defra/forms-engine-plugin/engine/types.js' +import { + type FormRequestPayload, + type FormStatus +} from '@defra/forms-engine-plugin/types' import { type FormDefinition, type FormMetadata, + type SecurityQuestionsEnum, type SubmitPayload, type SubmitResponsePayload } from '@defra/forms-model' -import { - type FormRequestPayload, - type FormStatus -} from '~/src/server/routes/types.js' - export interface FormsService { getFormMetadata: (slug: string) => Promise getFormDefinition: ( @@ -52,3 +52,19 @@ export interface OutputService { formMetadata?: FormMetadata ) => Promise } + +export interface SaveAndExitDetails { + form: { + id: string + status: FormStatus + isPreview: boolean + baseUrl: string + } + question: SecurityQuestionsEnum + invalidPasswordAttempts: number + state: object +} + +export interface SaveAndExitResumeDetails extends SaveAndExitDetails { + validPassword: boolean +} diff --git a/src/server/views/help/privacy-notice.html b/src/server/views/help/privacy-notice.html index 05f529e61..da7c06607 100644 --- a/src/server/views/help/privacy-notice.html +++ b/src/server/views/help/privacy-notice.html @@ -5,13 +5,13 @@ {% block content %}
-

{{ config.serviceName }} privacy notice

-

The {{ form.title }} form was created using ‘{{ config.serviceName }}’. This service is owned and operated by the Department for Environment, Food & Rural Affairs (Defra).

+

{{ config.serviceName }} - privacy notice

+

The {{ form.title }} form was created using the {{ config.serviceName }} service. This service is owned and operated by the Department for Environment, Food & Rural Affairs (Defra).

Who collects your personal data

-

The organisation that created the {{ form.title }} form using ‘{{ config.serviceName }}’ is the data controller of personal data they collect. If the data controller is outside the Defra legal entity, then Defra is the data processor.

+

The organisation that created the {{ form.title }} form using {{ config.serviceName }} is the controller of personal data they collect. If the controller is outside the Defra legal entity, then Defra is the processor.

Read the specific privacy notice for the {{ form.title }} form.

-

Defra also collects some data as a data controller. This privacy notice explains what personal data Defra collects and processes as a data controller through forms made with ‘{{ config.serviceName }}’.

+

Defra also collects some data as a controller. This privacy notice explains what personal data Defra collects and processes as a controller through forms made with {{ config.serviceName }}.

If you need further information about how Defra uses your personal data, email defraforms@defra.gov.uk.

If you want information and your associated rights you can email: data.protection@defra.gov.uk.

The data protection officer for Defra is responsible for checking that Defra complies with legislation. You can contact them at DefraGroupDataProtectionOfficer@defra.gov.uk.

@@ -19,24 +19,31 @@

Who collects your personal data

Data we collect from you and what we do with it

{% if config.googleAnalyticsTrackingId %} - -

If you give your consent, we use Google Analytics cookies to collect information about how you use ‘{{ config.serviceName }}’. Read the data privacy and security policy for Google Analytics.

+
+

If you give your consent, we use Google Analytics cookies to collect information about how you use {{ config.serviceName }}. Read the data privacy and security policy for Google Analytics.

Google Analytics processes information about:

  • your IP address
  • -
  • the pages you visit on ‘{{ config.serviceName }}’
  • -
  • how long you spend on each ‘{{ config.serviceName }}’ page
  • +
  • the pages you visit on {{ config.serviceName }}
  • +
  • how long you spend on each {{ config.serviceName }} page
  • how you got to the site
  • what you select while you’re visiting the site

Defra will make sure you cannot be directly identified by Google Analytics data. We do this by using Google Analytics’ IP address anonymisation feature and by removing any other personal data from the titles or URLs of the pages you visit.

Defra will not combine analytics information with other data sets in a way that would directly identify who you are.

- +
{% endif %} -

We use system logs to collect information about the usage of forms. The logs are stored in Amazon Web Services based in London.

-

We use the system logs {% if config.googleAnalyticsTrackingId %}and Google Analytics data{% endif %} to create anonymised reports about the performance of forms that use ‘{{ config.serviceName }}’. We use this data to improve forms, for example, if we discover a high number of drops offs at a certain point within a form. We may share this information with the data controller of the form.

-

If you email us feedback about a form that uses ‘{{ config.serviceName }}’, we’ll send your email address and any other personal information you choose to include in your email to the data controller for review. The data controller may use your personal information to reply to your query to update a form based on your feedback where it is appropriate.

+

We use system logs to collect information about the usage of forms.

+

We use the system logs {% if config.googleAnalyticsTrackingId %}and Google Analytics data{% endif %} to create anonymised reports about the performance of forms that use {{ config.serviceName }}. We use this data to improve forms, for example, if we discover a high number of incomplete forms.

+

If you email us feedback about a form that uses {{ config.serviceName }}, we’ll send your email address and any other personal information you choose to include in your email to the controller for review. The controller may use your personal data to reply to your query to update a form based on your feedback where it is appropriate.

+ +

Save and return to a form

+

You can save your form progress and return within {{ saveAndExitExpiryDays }} days. When saving your progress we ask you for:

+
    +
  • your email address
  • +
  • an answer to a security question
  • +

Lawful basis for processing your personal data

The lawful basis for processing your personal data is your consent.

@@ -68,7 +75,7 @@

Complaints

Personal information charter

Our personal information charter explains more about your rights over your personal data.

-

Last updated: 24 December 2024

+

Last updated: 29 August 2025

{% endblock %} diff --git a/src/server/views/save-and-exit-confirmation.html b/src/server/views/save-and-exit-confirmation.html index 7edeff255..c619e8840 100644 --- a/src/server/views/save-and-exit-confirmation.html +++ b/src/server/views/save-and-exit-confirmation.html @@ -2,8 +2,6 @@ {% from "govuk/components/panel/macro.njk" import govukPanel %} -{% set mainClasses = "govuk-main-wrapper--l" %} - {% block content %}
@@ -12,7 +10,7 @@ }) }}

What happens next

- We have sent a one-off link to {{ email }} which you can use to resume this form within 28 days. + We have sent a one-off link to {{ email }} which you can use to resume this form within {{ saveAndExitExpiryDays }} days.

This link will only work once. diff --git a/src/server/views/save-and-exit-details.html b/src/server/views/save-and-exit-details.html index 0caf52418..9f816182f 100644 --- a/src/server/views/save-and-exit-details.html +++ b/src/server/views/save-and-exit-details.html @@ -5,8 +5,6 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} -{% set mainClasses = "govuk-main-wrapper--l" %} - {% block content %}

diff --git a/src/server/views/save-and-exit/resume-error-locked.html b/src/server/views/save-and-exit/resume-error-locked.html new file mode 100644 index 000000000..4ed6495a2 --- /dev/null +++ b/src/server/views/save-and-exit/resume-error-locked.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+

You cannot resume your form

+

+ The answer to your security question was incorrect {{ maxPasswordAttempts }} times. You have run out of attempts to resume your form. +

+

+ You will need to start the form again. Your information will be securely deleted. +

+
+ {{ govukButton(buttons.continueButton) }} +
+
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-error.html b/src/server/views/save-and-exit/resume-error.html new file mode 100644 index 000000000..1c820f962 --- /dev/null +++ b/src/server/views/save-and-exit/resume-error.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+

{{ pageTitle }}

+

+ The link we emailed to you has expired or is invalid. Your information will be securely deleted. +

+

+ You will need to start the form again. +

+ {% if buttons.continueButton %} +
+ {{ govukButton(buttons.continueButton) }} +
+ {% endif %} +
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-password.html b/src/server/views/save-and-exit/resume-password.html new file mode 100644 index 000000000..fca4d6710 --- /dev/null +++ b/src/server/views/save-and-exit/resume-password.html @@ -0,0 +1,40 @@ +{% extends "layout.html" %} + +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% block content %} +
+
+ {% if errors | length %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errors + }) }} + {% endif %} + +

{{ pageTitle }}

+

+ Enter the answer to your security question to retrieve your information and continue with your form. +

+

+ You have {{ attemptsLeft }} attempts to enter your answer. Make sure your answer matches the exact style and format when used when saving your progress. +

+
+ + {{ govukInput(fields.securityAnswer) }} + + {{ govukWarningText({ + text: "This will use up your save link. To save progress again, you will need to repeat the save process and generate another link.", + iconFallbackText: "Warning" + }) }} + +
+ {{ govukButton(buttons.continueButton) }} +
+
+
+
+{% endblock %} diff --git a/src/server/views/save-and-exit/resume-success.html b/src/server/views/save-and-exit/resume-success.html new file mode 100644 index 000000000..c413da6a0 --- /dev/null +++ b/src/server/views/save-and-exit/resume-success.html @@ -0,0 +1,29 @@ +{% extends "layout.html" %} + +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% block content %} +
+
+

Welcome back to your form

+

+ You will return to the page where you saved your progress unless new questions have been added to the form. +

+ + {{ govukWarningText({ + text: "If new questions have been added to the form, you will be taken to the earliest new question.", + iconFallbackText: "Warning" + }) }} + +

+ You will need to repeat the save process again and generate a new link if you want to save your progress in the future. +

+
+
+ {{ govukButton(buttons.continueButton) }} +
+
+
+
+{% endblock %} diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index c3c8ee505..4b976d1a7 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,10 +5,6 @@ import { type Plugin } from '@hapi/hapi' import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' -import { - type FormRequest, - type FormRequestPayload -} from '~/src/server/routes/types.js' import { type CacheService } from '~/src/server/services/index.js' declare module '@hapi/hapi' { @@ -16,7 +12,7 @@ declare module '@hapi/hapi' { // props from plugins which doesn't export @types interface PluginProperties { crumb: { - generate?: (request: Request | FormRequest | FormRequestPayload) => string + generate?: (request: Request) => string } } diff --git a/test/form/legacy-redirects.test.js b/test/form/legacy-redirects.test.js index c8ad2cb0b..482e2a83c 100644 --- a/test/form/legacy-redirects.test.js +++ b/test/form/legacy-redirects.test.js @@ -1,8 +1,8 @@ +import { FormStatus } from '@defra/forms-engine-plugin/types' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' -import { FormStatus } from '~/src/server/routes/types.js' describe('Legacy Redirect Routes', () => { /** @type {import('@hapi/hapi').Server} */ diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index 241a28f44..0e3cd73f2 100644 --- a/test/form/save-and-exit.test.js +++ b/test/form/save-and-exit.test.js @@ -37,7 +37,7 @@ describe('Save and exit', () => { it('shows the details page', async () => { const options = { method: 'GET', - url: '/save-and-exit/draft/basic' + url: '/save-and-exit/basic' } const { container } = await renderResponse(server, options) @@ -68,7 +68,7 @@ describe('Save and exit', () => { it('shows the details page with errors', async () => { const options = { method: 'POST', - url: '/save-and-exit/draft/basic', + url: '/save-and-exit/basic', payload: { email: '', emailConfirmation: '', @@ -89,13 +89,15 @@ describe('Save and exit', () => { expect($heading).toBeInTheDocument() expect($errorItems[0]).toHaveTextContent('Enter an email address') expect($errorItems[1]).toHaveTextContent('Choose a security question') - expect($errorItems[2]).toHaveTextContent('Enter a security answer') + expect($errorItems[2]).toHaveTextContent( + 'Enter an answer to the security question' + ) }) it('posts details page successfully', async () => { const options = { method: 'POST', - url: '/save-and-exit/draft/basic', + url: '/save-and-exit/basic', payload: { email: 'enrique.chase@defra.gov.uk', emailConfirmation: 'enrique.chase@defra.gov.uk', @@ -107,15 +109,13 @@ describe('Save and exit', () => { const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(response.headers.location).toBe( - '/save-and-exit/draft/basic/confirmation' - ) + expect(response.headers.location).toBe('/save-and-exit/basic/confirmation') const headers = getCookieHeader(response, ['session', 'crumb']) const { response: response2 } = await renderResponse(server, { method: 'get', - url: '/save-and-exit/draft/basic/confirmation', + url: '/save-and-exit/basic/confirmation', headers }) @@ -125,7 +125,7 @@ describe('Save and exit', () => { it('confirmation page errors if no details are flashed', async () => { const options = { method: 'GET', - url: '/save-and-exit/draft/basic/confirmation' + url: '/save-and-exit/basic/confirmation' } const { response } = await renderResponse(server, options)