diff --git a/.env.test b/.env.test index 05c0523ec..0fa133722 100644 --- a/.env.test +++ b/.env.test @@ -22,7 +22,7 @@ export SYSTEM_GITHUB_TOKEN="github_token" # FormSG keys export SITE_CREATE_FORM_KEY="site_form_key" export SITE_LAUNCH_FORM_KEY="site_launch_form_key" -export SITE_CLONE_FORM_KEY="site_clone_form_key" +export GGS_REPAIR_FORM_KEY="ggs_repair_form_key" # Required to connect to DynamoDB export AWS_ACCESS_KEY_ID="abc123" diff --git a/.platform/hooks/predeploy/06_fetch_ssm_parameters.sh b/.platform/hooks/predeploy/06_fetch_ssm_parameters.sh index b9f375928..29e7770ac 100644 --- a/.platform/hooks/predeploy/06_fetch_ssm_parameters.sh +++ b/.platform/hooks/predeploy/06_fetch_ssm_parameters.sh @@ -82,6 +82,7 @@ ENV_VARS=( "STEP_FUNCTIONS_ARN" "SYSTEM_GITHUB_TOKEN" "UPTIME_ROBOT_API_KEY" + "GGS_REPAIR_FORM_KEY" ) echo "Set AWS region" diff --git a/CHANGELOG.md b/CHANGELOG.md index e25c30ad9..ca732537a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v0.51.0](https://github.com/isomerpages/isomercms-backend/compare/v0.50.0...v0.51.0) + +- feat(quickie): correct staging url [`#999`](https://github.com/isomerpages/isomercms-backend/pull/999) +- feat(GH<->GGs): Repair GGs [`#996`](https://github.com/isomerpages/isomercms-backend/pull/996) +- 0.50.0 to develop [`#993`](https://github.com/isomerpages/isomercms-backend/pull/993) +- build(deps): bump crypto-js from 4.1.1 to 4.2.0 [`#995`](https://github.com/isomerpages/isomercms-backend/pull/995) + #### [v0.50.0](https://github.com/isomerpages/isomercms-backend/compare/v0.49.0...v0.50.0) +> 25 October 2023 + - chore(auth): upgrade auth redirect endpoint to use v2 [`#986`](https://github.com/isomerpages/isomercms-backend/pull/986) - Feat/quickie/site-create-form [`#985`](https://github.com/isomerpages/isomercms-backend/pull/985) - refactor(ff): make site launch flag a bool flag [`#958`](https://github.com/isomerpages/isomercms-backend/pull/958) diff --git a/package-lock.json b/package-lock.json index 668f5f2aa..dadab3128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isomercms", - "version": "0.50.0", + "version": "0.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "isomercms", - "version": "0.50.0", + "version": "0.51.0", "dependencies": { "@aws-sdk/client-amplify": "^3.370.0", "@aws-sdk/client-cloudwatch-logs": "^3.370.0", @@ -34,7 +34,7 @@ "cookie-parser": "~1.4.5", "cors": "^2.8.5", "cross-fetch": "^4.0.0", - "crypto-js": "^4.1.1", + "crypto-js": "^4.2.0", "dd-trace": "^4.7.0", "debug": "~2.6.9", "dotenv": "^16.3.1", @@ -6625,9 +6625,9 @@ } }, "node_modules/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/crypto-randomuuid": { "version": "1.0.0", diff --git a/package.json b/package.json index 7433ca3d3..ffb377931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isomercms", - "version": "0.50.0", + "version": "0.51.0", "private": true, "scripts": { "build": "tsc -p tsconfig.build.json", @@ -50,7 +50,7 @@ "cookie-parser": "~1.4.5", "cors": "^2.8.5", "cross-fetch": "^4.0.0", - "crypto-js": "^4.1.1", + "crypto-js": "^4.2.0", "dd-trace": "^4.7.0", "debug": "~2.6.9", "dotenv": "^16.3.1", diff --git a/src/config/config.ts b/src/config/config.ts index a66deb16f..4cd28e1b0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -283,6 +283,20 @@ const config = convict({ format: "required-string", default: "", }, + siteLaunchFormKey: { + doc: "FormSG API key for site launch form", + env: "SITE_LAUNCH_FORM_KEY", + sensitive: true, + format: "required-string", + default: "", + }, + ggsRepairFormKey: { + doc: "FormSG API key for GGs repair form", + env: "GGS_REPAIR_FORM_KEY", + sensitive: true, + format: "required-string", + default: "", + }, }, postman: { apiKey: { diff --git a/src/routes/formsg/formsgGGsRepair.ts b/src/routes/formsg/formsgGGsRepair.ts new file mode 100644 index 000000000..7c87b54d7 --- /dev/null +++ b/src/routes/formsg/formsgGGsRepair.ts @@ -0,0 +1,264 @@ +/* eslint-disable import/prefer-default-export */ +import path from "path" + +import { + DecryptedContentAndAttachments, + DecryptedFile, +} from "@opengovsg/formsg-sdk/dist/types" +import express, { RequestHandler } from "express" +import { + ResultAsync, + combineWithAllErrors, + errAsync, + fromPromise, + okAsync, +} from "neverthrow" + +import { config } from "@config/config" + +import { EFS_VOL_PATH_STAGING_LITE } from "@root/constants" +import GitFileSystemError from "@root/errors/GitFileSystemError" +import InitializationError from "@root/errors/InitializationError" +import { consoleLogger } from "@root/logger/console.logger" +import logger from "@root/logger/logger" +import { attachFormSGHandler } from "@root/middleware" +import GitFileSystemService from "@root/services/db/GitFileSystemService" +import ReposService from "@root/services/identity/ReposService" +import { mailer } from "@root/services/utilServices/MailClient" +import { getField, getFieldsFromTable, getId } from "@root/utils/formsg-utils" + +const GGS_REPAIR_FORM_KEY = config.get("formSg.ggsRepairFormKey") + +interface FormsgGGsRepairRouterProps { + gitFileSystemService: GitFileSystemService + reposService: ReposService +} + +interface RepairEmailProps { + clonedRepos: string[] + syncedRepos: string[] + errors: GitFileSystemError[] + requesterEmail: string +} + +const REQUESTER_EMAIL_FIELD = "Email" +const OPTION_TO_SUBMIT_CSV = "Do you want to upload list of sites as a CSV?" +const ATTACHMENT = "Attachment" +const REPO_NAME_FIELD = "Table (Repo Name (in GitHub))" + +export class FormsgGGsRepairRouter { + private readonly gitFileSystemService: FormsgGGsRepairRouterProps["gitFileSystemService"] + + private readonly reposService: FormsgGGsRepairRouterProps["reposService"] + + constructor({ + gitFileSystemService, + reposService, + }: FormsgGGsRepairRouterProps) { + this.gitFileSystemService = gitFileSystemService + this.reposService = reposService + } + + getGGsRepairFormSubmission: RequestHandler< + never, + Record, + { data: { submissionId: string } }, + never, + { submission: DecryptedContentAndAttachments } + > = async (req, res) => { + const { responses } = res.locals.submission.content + + const requesterEmail = getField(responses, REQUESTER_EMAIL_FIELD) + const optionToSubmitCsv = getField(responses, OPTION_TO_SUBMIT_CSV) + const repoNames: string[] = [] + if (optionToSubmitCsv === "Yes") { + const attachmentId = getId(responses, ATTACHMENT) + if (!attachmentId) { + throw new Error("No attachment id") + } + const decryptedFile: DecryptedFile = + res.locals.submission.attachments?.[attachmentId] + const reposCsv = Buffer.from(decryptedFile.content).toString() + if (!reposCsv.startsWith("repo_name")) { + logger.error("Invalid csv format") + return + } + const repos = reposCsv.split("\n").slice(1) + repos.forEach((repo) => { + repoNames.push(repo) + }) + if (repoNames.length === 0) { + logger.error("No repo name provided") + return + } + } else { + const repoNamesFromTable = getFieldsFromTable(responses, REPO_NAME_FIELD) + + if (!repoNamesFromTable) { + logger.error("No repo name provided") + return + } + repoNamesFromTable.forEach((repoName) => { + if (typeof repoName === "string") { + // actually wont happen based on our formsg form, but this code + // is added defensively + repoNames.push(repoName) + } else { + repoNames.push(repoName[0]) + } + }) + } + + if (!requesterEmail?.endsWith("@open.gov.sg")) { + logger.error("Requester email is not from @open.gov.sg") + return + } + this.handleGGsFormSubmission(repoNames, requesterEmail) + } + + handleGGsFormSubmission = (repoNames: string[], requesterEmail: string) => { + const repairs: ResultAsync[] = [] + + const clonedStagingRepos: string[] = [] + const syncedStagingAndStagingLiteRepos: string[] = [] + repoNames.forEach((repoName) => { + const repoUrl = `git@github.com:isomerpages/${repoName}` + + repairs.push( + this.doesRepoNeedClone(repoName) + .andThen(() => { + const isStaging = true + return ( + this.gitFileSystemService + // Repo does not exist in EFS, clone it + .cloneBranch(repoName, isStaging) + .andThen(() => { + // take note of repos that cloned successfully + clonedStagingRepos.push(repoName) + return okAsync(true) + }) + ) + }) + .orElse((error) => { + if (error === false) { + // Repo exists in EFS, no need to clone, but continue with syncing + return okAsync(false) + } + return errAsync(error) + }) + .andThen(() => + // repo exists in efs, but we need to pull for staging and reset staging lite + this.gitFileSystemService + .pull(repoName, "staging") + .andThen(() => + fromPromise( + this.reposService.setUpStagingLite( + path.join(EFS_VOL_PATH_STAGING_LITE, repoName), + repoUrl + ), + (error) => + new GitFileSystemError( + `Error setting up staging lite for repo ${repoName}: ${error}` + ) + ) + ) + .andThen((result) => { + // take note of repos that synced successfully + syncedStagingAndStagingLiteRepos.push(repoName) + return okAsync(result) + }) + ) + ) + }) + + let errors: GitFileSystemError[] = [] + combineWithAllErrors(repairs) + .orElse((error) => { + errors = error + // send one final email about success and failures + return okAsync([]) + }) + .andThen(() => + fromPromise( + this.sendEmail({ + requesterEmail, + clonedRepos: clonedStagingRepos, + syncedRepos: syncedStagingAndStagingLiteRepos, + errors, + }), + (error) => { + const emailContent = `Sites have been repaired ${ + errors ? `with errors ${errors}` : `without errors` + } and clonedRepos ${clonedStagingRepos} and syncedRepos ${syncedStagingAndStagingLiteRepos}` + // Logging information in case email sending fails + consoleLogger.error( + `There was an error sending email to ${requesterEmail}: ${error}\n The content of the email is: ${emailContent}` + ) + } + ) + ) + } + + async sendEmail({ + requesterEmail, + clonedRepos, + syncedRepos, + errors, + }: RepairEmailProps) { + const subject = `[Isomer] Site Repair Report` + const errorReport = + errors.length > 0 + ? `The following errors were observed while repairing the sites: +` + : "" + + const clonedReposReport = + clonedRepos.length > 0 + ? `The following sites were cloned to EFS: +` + : "" + + const syncedReposReport = + syncedRepos.length > 0 + ? `The following sites were synced to EFS: +` + : "" + + const html = errorReport + clonedReposReport + syncedReposReport + await mailer.sendMail(requesterEmail, subject, html) + } + + doesRepoNeedClone(repoName: string): ResultAsync { + return this.gitFileSystemService + .isGitInitialized(repoName) + .andThen((isRepoInEfs) => { + if (isRepoInEfs) { + return errAsync(false) + } + return okAsync(true) + }) + .orElse(() => okAsync(true)) + } + + getRouter() { + const router = express.Router({ mergeParams: true }) + if (!GGS_REPAIR_FORM_KEY) { + throw new InitializationError( + "Required GGS_REPAIR_FORM_KEY environment variable is empty." + ) + } + router.post( + "/repair-ggs", + attachFormSGHandler(GGS_REPAIR_FORM_KEY), + this.getGGsRepairFormSubmission + ) + + return router + } +} diff --git a/src/routes/formsgSiteCreation.ts b/src/routes/formsg/formsgSiteCreation.ts similarity index 96% rename from src/routes/formsgSiteCreation.ts rename to src/routes/formsg/formsgSiteCreation.ts index 7a2a21801..a202a22ef 100644 --- a/src/routes/formsgSiteCreation.ts +++ b/src/routes/formsg/formsgSiteCreation.ts @@ -1,4 +1,4 @@ -import { DecryptedContent } from "@opengovsg/formsg-sdk/dist/types" +import { DecryptedContentAndAttachments } from "@opengovsg/formsg-sdk/dist/types" import autoBind from "auto-bind" import express, { RequestHandler } from "express" @@ -29,7 +29,7 @@ export interface FormsgRouterProps { gitFileSystemService: GitFileSystemService } -export class FormsgRouter { +export class FormsgSiteCreateRouter { private readonly usersService: FormsgRouterProps["usersService"] private readonly infraService: FormsgRouterProps["infraService"] @@ -53,11 +53,11 @@ export class FormsgRouter { Record, { data: { submissionId: string } }, never, - { submission: DecryptedContent } + { submission: DecryptedContentAndAttachments } > = async (req, res) => { // 1. Extract arguments const { submissionId } = req.body.data - const { responses } = res.locals.submission + const { responses } = res.locals.submission.content const requesterEmail = getField(responses, REQUESTER_EMAIL_FIELD) const siteName = getField(responses, SITE_NAME_FIELD) const repoName = getField(responses, REPO_NAME_FIELD) diff --git a/src/routes/formsgSiteLaunch.ts b/src/routes/formsg/formsgSiteLaunch.ts similarity index 95% rename from src/routes/formsgSiteLaunch.ts rename to src/routes/formsg/formsgSiteLaunch.ts index 81010b34e..2fdba9420 100644 --- a/src/routes/formsgSiteLaunch.ts +++ b/src/routes/formsg/formsgSiteLaunch.ts @@ -1,4 +1,4 @@ -import { DecryptedContent } from "@opengovsg/formsg-sdk/dist/types" +import { DecryptedContentAndAttachments } from "@opengovsg/formsg-sdk/dist/types" import autoBind from "auto-bind" import axios from "axios" import express, { RequestHandler } from "express" @@ -26,12 +26,12 @@ import { DigResponse, DigType } from "@root/types/dig" import UsersService from "@services/identity/UsersService" import InfraService from "@services/infra/InfraService" -const { SITE_LAUNCH_FORM_KEY } = process.env +const SITE_LAUNCH_FORM_KEY = config.get("formSg.siteLaunchFormKey") const REQUESTER_EMAIL_FIELD = "Government Email" const SITE_LAUNCH_LIST = "Site Launch Details (Root Domain (eg. blah.moe.edu.sg), Redirection domain, Repo name (eg. moe-standrewsjc), Agency Email)" -export interface FormsgRouterProps { +export interface FormsgSiteLaunchRouterProps { usersService: UsersService infraService: InfraService } @@ -138,11 +138,11 @@ export class FormsgSiteLaunchRouter { return ok({ ...launchSiteResult.value, repoName, requesterEmail }) } - private readonly usersService: FormsgRouterProps["usersService"] + private readonly usersService: FormsgSiteLaunchRouterProps["usersService"] - private readonly infraService: FormsgRouterProps["infraService"] + private readonly infraService: FormsgSiteLaunchRouterProps["infraService"] - constructor({ usersService, infraService }: FormsgRouterProps) { + constructor({ usersService, infraService }: FormsgSiteLaunchRouterProps) { this.usersService = usersService this.infraService = infraService // We need to bind all methods because we don't invoke them from the class directly @@ -157,11 +157,11 @@ export class FormsgSiteLaunchRouter { string, { data: { submissionId: string } }, never, - { submission: DecryptedContent } + { submission: DecryptedContentAndAttachments } > = async (req, res) => { // 1. Extract arguments const { submissionId } = req.body.data - const { responses } = res.locals.submission + const { responses } = res.locals.submission.content const siteLaunchList = getFieldsFromTable(responses, SITE_LAUNCH_LIST) const formResponses: FormResponsesProps[] = siteLaunchList?.map((element) => diff --git a/src/server.js b/src/server.js index d27790339..3e7a172ab 100644 --- a/src/server.js +++ b/src/server.js @@ -150,10 +150,15 @@ const FRONTEND_URL = config.get("app.frontendUrl") // Import routes const { errorHandler } = require("@middleware/errorHandler") -const { FormsgRouter } = require("@routes/formsgSiteCreation") -const { FormsgSiteLaunchRouter } = require("@routes/formsgSiteLaunch") const { AuthRouter } = require("@routes/v2/auth") +const { FormsgGGsRepairRouter } = require("@root/routes/formsg/formsgGGsRepair") +const { + FormsgSiteCreateRouter, +} = require("@root/routes/formsg/formsgSiteCreation") +const { + FormsgSiteLaunchRouter, +} = require("@root/routes/formsg/formsgSiteLaunch") const { AuthService } = require("@services/utilServices/AuthService") // growthbook polyfills @@ -366,7 +371,7 @@ const authV2Router = new AuthRouter({ statsMiddleware, sgidAuthRouter, }) -const formsgRouter = new FormsgRouter({ +const formsgSiteCreateRouter = new FormsgSiteCreateRouter({ usersService, infraService, gitFileSystemService, @@ -376,6 +381,11 @@ const formsgSiteLaunchRouter = new FormsgSiteLaunchRouter({ infraService, }) +const formsgGGsRepairRouter = new FormsgGGsRepairRouter({ + gitFileSystemService, + reposService, +}) + const app = express() if (isSecure) { @@ -420,8 +430,9 @@ app.use("/v2", authenticatedSubrouterV2) app.use("/v2/sites/:siteName", authenticatedSitesSubrouterV2) // FormSG Backend handler routes -app.use("/formsg", formsgRouter.getRouter()) +app.use("/formsg", formsgSiteCreateRouter.getRouter()) app.use("/formsg", formsgSiteLaunchRouter.getRouter()) +app.use("/formsg", formsgGGsRepairRouter.getRouter()) // catch unknown routes app.use((req, res, next) => { diff --git a/src/services/db/GitFileSystemService.ts b/src/services/db/GitFileSystemService.ts index 207bcf254..bc29506f1 100644 --- a/src/services/db/GitFileSystemService.ts +++ b/src/services/db/GitFileSystemService.ts @@ -394,8 +394,8 @@ export default class GitFileSystemService { if (error instanceof GitError) { return new GitFileSystemError( isStaging - ? "Unable to clone whole repo" - : "Unable to clone staging lite branch" + ? `Unable to clone whole repo for ${repoName}` + : `Unable to clone staging lite branch for ${repoName}` ) } diff --git a/src/services/identity/ReposService.ts b/src/services/identity/ReposService.ts index 1b5372c21..fde74a956 100644 --- a/src/services/identity/ReposService.ts +++ b/src/services/identity/ReposService.ts @@ -258,30 +258,7 @@ export default class ReposService { .checkout("staging") // reset local branch back to staging // Make sure the local path is empty, just in case dir was used on a previous attempt. - fs.rmSync(`${stgLiteDir}`, { recursive: true, force: true }) - // create a empty folder stgLiteDir - fs.mkdirSync(stgLiteDir) - - // Create staging lite branch in other repo path - await this.simpleGit - .cwd(stgLiteDir) - .clone(repoUrl, stgLiteDir) - .checkout("staging") - .rm(["-r", "images"]) - .rm(["-r", "files"]) - - // Clear git - fs.rmSync(`${stgLiteDir}/.git`, { recursive: true, force: true }) - - // Prepare git repo - await this.simpleGit - .cwd(stgLiteDir) - .init() - .checkoutLocalBranch("staging-lite") - .add(".") - .commit("Initial commit") - .addRemote("origin", repoUrl) - .push(["origin", "staging-lite", "-f"]) + await this.setUpStagingLite(stgLiteDir, repoUrl) } createDnsIndirectionFile = ( @@ -390,4 +367,35 @@ export const createRecords = (zoneId: string): Record[] => { }) .map(() => undefined) } + + async setUpStagingLite(stgLiteDir: string, repoUrl: string) { + fs.rmSync(`${stgLiteDir}`, { recursive: true, force: true }) + // create a empty folder stgLiteDir + fs.mkdirSync(stgLiteDir) + + // note: for some reason, combining below commands led to race conditions + // so we have to do it separately + // Create staging lite branch in other repo path + await this.simpleGit.cwd(stgLiteDir).clone(repoUrl, stgLiteDir) + await this.simpleGit.cwd(stgLiteDir).pull() // some repos are large, clone takes time + await this.simpleGit + .cwd(stgLiteDir) + .checkout("staging") + .rm(["-r", "images"]) + .rm(["-r", "files"]) + + // Clear git + fs.rmSync(`${stgLiteDir}/.git`, { recursive: true, force: true }) + + // Prepare git repo + await this.simpleGit + .cwd(stgLiteDir) + .init() + .checkoutLocalBranch("staging-lite") + .add(".") + .commit("Initial commit") + .addRemote("origin", repoUrl) + .push(["origin", "staging-lite", "-f"]) + return stgLiteDir + } } diff --git a/src/services/identity/SitesService.ts b/src/services/identity/SitesService.ts index 3558ddf56..d502253eb 100644 --- a/src/services/identity/SitesService.ts +++ b/src/services/identity/SitesService.ts @@ -23,6 +23,7 @@ import { PreviewInfo } from "@root/types/previewInfo" import type { RepositoryData, SiteUrls } from "@root/types/repoInfo" import { SiteInfo } from "@root/types/siteInfo" import { Brand } from "@root/types/util" +import { isReduceBuildTimesWhitelistedRepo } from "@root/utils/growthbook-utils" import { safeJsonParse } from "@root/utils/json" import RepoService from "@services/db/RepoService" import { ConfigYmlService } from "@services/fileServices/YmlFileServices/ConfigYmlService" @@ -259,6 +260,22 @@ class SitesService { .orElse(() => okAsync(this.extractAuthorEmail(commit))) } + updateDbWithStagingUrl(site: Site, stagingUrl: StagingPermalink) { + // Non-blocking control flow + this.siteRepository.update( + { + deployment: { + stagingUrl, + }, + }, + { + where: { + id: site.id, + }, + } + ) + } + // Tries to get the site urls in the following order: // 1. From the deployments database table // 2. From the config.yml file @@ -291,11 +308,53 @@ class SitesService { // and legacy sites using github login will not. // Hence, for such sites, extract their URLs through // the _config.yml or github description - .andThen((site) => - site?.deployment - ? okAsync(site.deployment) - : okAsync({ stagingUrl: undefined, productionUrl: undefined }) - ) + .andThen((site) => { + if ( + !site || + !site.deployment || + !site.deployment.stagingUrl || + !site.deployment.productionUrl + ) { + // Guard clause, this will throw a not found error later on + return okAsync({ + stagingUrl: undefined, + productionUrl: undefined, + }) + } + + if (!sessionData.growthbook) { + // Not enough info to determine if the feature flag is synced with db + return okAsync(site.deployment) + } + + const featureFlagSyncedWithDb = + (isReduceBuildTimesWhitelistedRepo(sessionData.growthbook) && + site?.deployment?.stagingUrl.includes("staging-lite.")) || + // useful for rollbacks + (!isReduceBuildTimesWhitelistedRepo(sessionData.growthbook) && + site?.deployment?.stagingUrl.includes("staging.")) + + if (featureFlagSyncedWithDb) { + return okAsync(site.deployment) + } + + let stagingUrl: StagingPermalink + if (isReduceBuildTimesWhitelistedRepo(sessionData.growthbook)) { + stagingUrl = Brand.fromString( + `https://staging-lite.${site.deployment.stagingLiteHostingId}.amplifyapp.com` + ) + } else { + stagingUrl = Brand.fromString( + `https://staging.${site.deployment.hostingId}.amplifyapp.com` + ) + } + // Non-blocking control flow + this.updateDbWithStagingUrl(site, stagingUrl) + return okAsync({ + ...site.deployment, + stagingUrl, + }) + }) .andThen(({ stagingUrl, productionUrl }) => { const siteUrls = { staging: stagingUrl, diff --git a/src/services/middlewareServices/FormsProcessingService.ts b/src/services/middlewareServices/FormsProcessingService.ts index 4aa958aed..22e63a49a 100644 --- a/src/services/middlewareServices/FormsProcessingService.ts +++ b/src/services/middlewareServices/FormsProcessingService.ts @@ -1,7 +1,5 @@ -import { - DecryptParams, - DecryptedContent, -} from "@opengovsg/formsg-sdk/dist/types" +import Crypto from "@opengovsg/formsg-sdk/dist/crypto" +import Webhooks from "@opengovsg/formsg-sdk/dist/webhooks" import { Request, Response, NextFunction } from "express" import logger from "@logger/logger" @@ -10,15 +8,8 @@ import { AuthError } from "@errors/AuthError" import { UnprocessableError } from "@errors/UnprocessableError" export interface FormsSdk { - webhooks: { - authenticate: (header: string, uri: string) => void - } - crypto: { - decrypt: ( - formCreateKey: string, - decryptParams: DecryptParams - ) => DecryptedContent | null - } + webhooks: Webhooks + crypto: Crypto } export default class FormsProcessingService { formsg: FormsSdk @@ -47,12 +38,12 @@ export default class FormsProcessingService { } } - decrypt = ({ formKey }: { formKey: string }) => ( + decrypt = ({ formKey }: { formKey: string }) => async ( req: Request, res: Response, next: NextFunction - ): void => { - const submission = this.formsg.crypto.decrypt( + ): Promise => { + const submission = await this.formsg.crypto.decryptWithAttachments( formKey, // If `verifiedContent` is provided in `req.body.data`, the return object // will include a verified key. diff --git a/src/services/middlewareServices/__tests__/FormsProcessingService.spec.ts b/src/services/middlewareServices/__tests__/FormsProcessingService.spec.ts index 3e5a297db..b3a1be3dd 100644 --- a/src/services/middlewareServices/__tests__/FormsProcessingService.spec.ts +++ b/src/services/middlewareServices/__tests__/FormsProcessingService.spec.ts @@ -30,7 +30,7 @@ const mockFormsg = { authenticate: jest.fn(), }, crypto: { - decrypt: jest.fn(), + decryptWithAttachments: jest.fn(), }, } const MOCK_POST_URI = `https://${mockReq.get("host")}${mockReq.baseUrl}${ @@ -109,17 +109,17 @@ describe("FormSG Processing Service", () => { }) it("should call decrypt successfully, store submission data and call next() when the call to decrypt is successful", async () => { // Arrange - mockFormsg.crypto.decrypt.mockReturnValue(MOCK_SUBMISSION) + mockFormsg.crypto.decryptWithAttachments.mockReturnValue(MOCK_SUBMISSION) const decryptMiddleware = FormsProcessingService.decrypt({ formKey: MOCK_FORM_KEY, }) // Act - decryptMiddleware(mockReq, mockRes, mockNext) + await decryptMiddleware(mockReq, mockRes, mockNext) // Assert expect(mockNext).toHaveBeenCalled() - expect(mockFormsg.crypto.decrypt).toHaveBeenCalledWith( + expect(mockFormsg.crypto.decryptWithAttachments).toHaveBeenCalledWith( MOCK_FORM_KEY, MOCK_DATA ) @@ -128,7 +128,7 @@ describe("FormSG Processing Service", () => { it("should not call next handler if decrypt fails", async () => { // Arrange - mockFormsg.crypto.decrypt.mockReturnValue(null) + mockFormsg.crypto.decryptWithAttachments.mockReturnValue(null) const decryptMiddleware = FormsProcessingService.decrypt({ formKey: MOCK_FORM_KEY, }) @@ -137,8 +137,8 @@ describe("FormSG Processing Service", () => { const result = () => decryptMiddleware(mockReq, mockRes, mockNext) // Assert - expect(result).toThrow(UnprocessableError) - expect(mockFormsg.crypto.decrypt).toHaveBeenCalledWith( + await expect(result).rejects.toThrow(UnprocessableError) + expect(mockFormsg.crypto.decryptWithAttachments).toHaveBeenCalledWith( MOCK_FORM_KEY, MOCK_DATA ) diff --git a/src/utils/formsg-utils.ts b/src/utils/formsg-utils.ts index 54417657b..a3683b94e 100644 --- a/src/utils/formsg-utils.ts +++ b/src/utils/formsg-utils.ts @@ -10,6 +10,15 @@ export function getField( return response?.answer?.trim() } +export function getId( + responses: FormField[], + name: string +): string | undefined { + const response = responses.find(({ question }) => question === name) + + return response?._id +} + function trimAllStrings( responseArray: string[] | string[][] ): string[] | string[][] {