diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78a626960..d5c1f8f1e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,7 @@ on: - "!skipci*" concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }} + group: ${{ github.ref_name }} permissions: id-token: write @@ -108,38 +108,8 @@ jobs: echo "application_endpoint=$APPLICATION_ENDPOINT" >> $GITHUB_OUTPUT echo "## Application Endpoint" >> $GITHUB_STEP_SUMMARY echo "<$APPLICATION_ENDPOINT>" >> $GITHUB_STEP_SUMMARY - - id: api_name - run: | - API_NAME=$(./scripts/output.sh app-api ApiGatewayRestApiName $STAGE_PREFIX$branch_name) - echo "api_name=$API_NAME" >> $GITHUB_OUTPUT - - id: branch_name - run: | - echo "branch_name=$branch_name" >> $GITHUB_OUTPUT - - id: cognito_user_pool_client_id - run: | - COGNITO_USER_POOL_CLIENT_ID=$(./scripts/output.sh ui-auth UserPoolClientId $STAGE_PREFIX$branch_name) - echo "cognito_user_pool_client_id=$COGNITO_USER_POOL_CLIENT_ID" >> $GITHUB_OUTPUT - - id: cognito_user_pool_id_encrypted - run: | - COGNITO_USER_POOL_ID=$(./scripts/output.sh ui-auth UserPoolId $STAGE_PREFIX$branch_name) - COGNITO_USER_POOL_ID_ENCRYPTED=$(echo -n "$COGNITO_USER_POOL_ID" | openssl enc -e -aes-256-cbc -a -pbkdf2 -iter 10000 -k "${{ secrets.CODE_CLIMATE_ID }}") - echo "cognito_user_pool_id_encrypted<> $GITHUB_OUTPUT - echo $COGNITO_USER_POOL_ID_ENCRYPTED >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - id: region_encrypted - run: | - REGION=$(./scripts/output.sh app-api Region $STAGE_PREFIX$branch_name) - REGION_ENCRYPTED=$(echo -n "$REGION" | openssl enc -e -aes-256-cbc -a -pbkdf2 -iter 10000 -k "${{ secrets.CODE_CLIMATE_ID }}") - echo "region_encrypted<> $GITHUB_OUTPUT - echo $REGION_ENCRYPTED >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT outputs: - api_name: ${{ steps.api_name.outputs.api_name }} application_endpoint: ${{ steps.endpoint.outputs.application_endpoint }} - branch_name: ${{ steps.branch_name.outputs.branch_name }} - cognito_user_pool_client_id: ${{ steps.cognito_user_pool_client_id.outputs.cognito_user_pool_client_id }} - cognito_user_pool_id_encrypted: ${{ steps.cognito_user_pool_id_encrypted.outputs.cognito_user_pool_id_encrypted }} - region_encrypted: ${{ steps.region_encrypted.outputs.region_encrypted }} BRANCH_SPECIFIC_VARNAME_AWS_DEFAULT_REGION: ${{ steps.set_names.outputs.BRANCH_SPECIFIC_VARNAME_AWS_DEFAULT_REGION }} BRANCH_SPECIFIC_VARNAME_AWS_OIDC_ROLE_TO_ASSUME: ${{ steps.set_names.outputs.BRANCH_SPECIFIC_VARNAME_AWS_OIDC_ROLE_TO_ASSUME }} @@ -307,6 +277,8 @@ jobs: needs: - deploy - register-runner + - e2e-test + - a11y-tests if: ${{ always() && !cancelled() && needs.deploy.result == 'success' && github.ref_name != 'production' }} timeout-minutes: 60 runs-on: ubuntu-latest @@ -320,22 +292,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" - - name: Decrypt cognito_user_pool_id - run: | - COGNITO_USER_POOL_ID_ENCRYPTED=${{ needs.deploy.outputs.cognito_user_pool_id_encrypted }} - COGNITO_USER_POOL_ID_DECRYPTED=$(echo "$COGNITO_USER_POOL_ID_ENCRYPTED" | openssl base64 -d) - COGNITO_USER_POOL_ID=$(echo -n "$COGNITO_USER_POOL_ID_DECRYPTED" | openssl enc -d -aes-256-cbc -pbkdf2 -iter 10000 -k "${{ secrets.CODE_CLIMATE_ID }}") - echo "cognito_user_pool_id<> $GITHUB_ENV - echo $COGNITO_USER_POOL_ID >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - name: Decrypt region - run: | - REGION_ENCRYPTED=${{ needs.deploy.outputs.region_encrypted }} - REGION_DECRYPTED=$(echo "$REGION_ENCRYPTED" | openssl base64 -d) - REGION=$(echo -n "$REGION_DECRYPTED" | openssl enc -d -aes-256-cbc -pbkdf2 -iter 10000 -k "${{ secrets.CODE_CLIMATE_ID }}") - echo "region<> $GITHUB_ENV - echo $REGION >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - name: yarn install run: yarn install - name: Install Playwright Browsers @@ -344,10 +300,7 @@ jobs: run: yarn playwright test continue-on-error: true env: - API_URL: https://${{ needs.deploy.outputs.api_name }}.execute-api.${{ env.region }}.amazonaws.com/${{ needs.deploy.outputs.branch_name }} BASE_URL: ${{ needs.deploy.outputs.application_endpoint }} - COGNITO_USER_POOL_CLIENT_ID: ${{ needs.deploy.outputs.cognito_user_pool_client_id }} - COGNITO_USER_POOL_ID: ${{ env.cognito_user_pool_id }} CYPRESS_STATE_USER_EMAIL: ${{ secrets.CYPRESS_STATE_USER_EMAIL }} CYPRESS_STATE_USER_PASSWORD: ${{ secrets.CYPRESS_STATE_USER_PASSWORD }} CYPRESS_ADMIN_USER_EMAIL: ${{ secrets.CYPRESS_ADMIN_USER_EMAIL }} diff --git a/.github/workflows/destroy.yml b/.github/workflows/destroy.yml index b9e48295e..cf6c4ca3b 100644 --- a/.github/workflows/destroy.yml +++ b/.github/workflows/destroy.yml @@ -8,6 +8,9 @@ on: description: "Name of the environment to destroy:" required: true +concurrency: + group: ${{ inputs.environment || github.event.ref }} + permissions: id-token: write contents: read @@ -63,3 +66,17 @@ jobs: run: | ./run destroy --stage $STAGE_PREFIX$branch_name --verify false --service app-api ./run destroy --stage $STAGE_PREFIX$branch_name --verify false + + # Notify the integrations channel when a destroy action fails + notify_on_destroy_failure: + runs-on: ubuntu-latest + needs: + - destroy + if: ${{ failure() }} + steps: + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_TITLE: ":boom: A destroy action has failed on ${{ github.repository }}." + MSG_MINIMAL: true + SLACK_WEBHOOK: ${{ secrets.INTEGRATIONS_SLACK_WEBHOOK }} diff --git a/.github/workflows/scan_security-hub-jira-integration.yml b/.github/workflows/scan_security-hub-jira-integration.yml index a4527eabd..1ee337889 100644 --- a/.github/workflows/scan_security-hub-jira-integration.yml +++ b/.github/workflows/scan_security-hub-jira-integration.yml @@ -21,13 +21,12 @@ jobs: aws-region: ${{ secrets.AWS_DEFAULT_REGION }} role-to-assume: ${{ secrets.PRODUCTION_SYNC_OIDC_ROLE }} - name: Sync Security Hub and Jira - uses: Enterprise-CMCS/mac-fc-security-hub-visibility@v1.0.7 + uses: Enterprise-CMCS/mac-fc-security-hub-visibility@v2.0.9 with: jira-username: "mdct_github_service_account" jira-token: ${{ secrets.JIRA_ENT_USER_TOKEN }} - jira-host: jiraent.cms.gov jira-project-key: CMDCT jira-ignore-statuses: Done, Closed, Canceled jira-custom-fields: '{ "customfield_10100": "CMDCT-2280", "customfield_26700" : [{"id": "40105", "value": "MFP"}] }' aws-severities: CRITICAL, HIGH, MEDIUM - assign-jira-ticket-to: "MWTW" + jira-assignee: "MWTW" diff --git a/playwright.config.ts b/playwright.config.ts index b7a101826..7675f651d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ testDir: "./tests/e2e", testMatch: ["**/*.spec.js", "**/*.spec.ts"], /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -47,7 +47,7 @@ export default defineConfig({ webServer: { command: process.env.CI ? "" : "./run local", url: process.env.BASE_URL || "http://localhost:3000", - reuseExistingServer: process.env.CI || false, + reuseExistingServer: !!process.env.CI, stdout: "pipe", }, }); diff --git a/services/app-api/forms/wp.json b/services/app-api/forms/wp.json index 9e5afed0c..bdd22ae33 100644 --- a/services/app-api/forms/wp.json +++ b/services/app-api/forms/wp.json @@ -562,7 +562,7 @@ "content": "Initiative close-out information, to be completed as appropriate during MFP Work Plan revisions", "props": { "style": { - "paddingTop": ".5rem" + "padding": ".5rem" } } } diff --git a/services/app-api/handlers/banners/create.ts b/services/app-api/handlers/banners/create.ts index f393252df..cac466a4b 100644 --- a/services/app-api/handlers/banners/create.ts +++ b/services/app-api/handlers/banners/create.ts @@ -1,12 +1,12 @@ import handler from "../handler-lib"; // utils -import dynamoDb from "../../utils/dynamo/dynamodb-lib"; import { hasPermissions } from "../../utils/auth/authorization"; import { error } from "../../utils/constants/constants"; // types import { StatusCodes, UserRoles } from "../../utils/types"; import { number, object, string } from "yup"; import { validateData } from "../../utils/validation/validation"; +import { putBanner } from "../../storage/banners"; export const createBanner = handler(async (event, _context) => { if (!hasPermissions(event, [UserRoles.ADMIN])) { @@ -34,22 +34,19 @@ export const createBanner = handler(async (event, _context) => { ); if (validatedPayload) { - const params = { - TableName: process.env.BANNER_TABLE_NAME!, - Item: { - key: event.pathParameters.bannerId, - createdAt: Date.now(), - lastAltered: Date.now(), - lastAlteredBy: event?.headers["cognito-identity-id"], - title: validatedPayload.title, - description: validatedPayload.description, - link: validatedPayload.link, - startDate: validatedPayload.startDate, - endDate: validatedPayload.endDate, - }, + const newBanner = { + key: event.pathParameters.bannerId, + createdAt: Date.now(), + lastAltered: Date.now(), + lastAlteredBy: event?.headers["cognito-identity-id"], + title: validatedPayload.title, + description: validatedPayload.description, + link: validatedPayload.link, + startDate: validatedPayload.startDate, + endDate: validatedPayload.endDate, }; - await dynamoDb.put(params); - return { status: StatusCodes.CREATED, body: params }; + await putBanner(newBanner); + return { status: StatusCodes.CREATED, body: newBanner }; } } }); diff --git a/services/app-api/handlers/banners/delete.ts b/services/app-api/handlers/banners/delete.ts index be7a68e78..2862a7cc8 100644 --- a/services/app-api/handlers/banners/delete.ts +++ b/services/app-api/handlers/banners/delete.ts @@ -1,8 +1,8 @@ import handler from "../handler-lib"; // utils -import dynamoDb from "../../utils/dynamo/dynamodb-lib"; import { hasPermissions } from "../../utils/auth/authorization"; import { error } from "../../utils/constants/constants"; +import { deleteBanner as deleteBannerById } from "../../storage/banners"; // types import { StatusCodes, UserRoles } from "../../utils/types"; @@ -15,13 +15,8 @@ export const deleteBanner = handler(async (event, _context) => { } else if (!event?.pathParameters?.bannerId!) { throw new Error(error.NO_KEY); } else { - const params = { - TableName: process.env.BANNER_TABLE_NAME!, - Key: { - key: event?.pathParameters?.bannerId!, - }, - }; - await dynamoDb.delete(params); - return { status: StatusCodes.SUCCESS, body: params }; + const bannerId = event?.pathParameters?.bannerId!; + await deleteBannerById(bannerId); + return { status: StatusCodes.SUCCESS, body: { Key: bannerId } }; } }); diff --git a/services/app-api/handlers/banners/fetch.test.ts b/services/app-api/handlers/banners/fetch.test.ts index 1c5ddd358..86180b8ee 100644 --- a/services/app-api/handlers/banners/fetch.test.ts +++ b/services/app-api/handlers/banners/fetch.test.ts @@ -1,19 +1,20 @@ import { fetchBanner } from "./fetch"; -import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb"; -import { mockClient } from "aws-sdk-client-mock"; // utils import { proxyEvent } from "../../utils/testing/proxyEvent"; import { error } from "../../utils/constants/constants"; import { mockBannerResponse } from "../../utils/testing/setupJest"; +import { getBanner } from "../../storage/banners"; // types import { APIGatewayProxyEvent, StatusCodes } from "../../utils/types"; -const dynamoClientMock = mockClient(DynamoDBDocumentClient); - jest.mock("../../utils/auth/authorization", () => ({ isAuthorized: jest.fn().mockReturnValue(true), })); +jest.mock("../../storage/banners", () => ({ + getBanner: jest.fn(), +})); + const testEvent: APIGatewayProxyEvent = { ...proxyEvent, headers: { "cognito-identity-id": "test" }, @@ -31,15 +32,12 @@ let consoleSpy: { describe("Test fetchBanner API method", () => { beforeEach(() => { jest.restoreAllMocks(); - dynamoClientMock.reset(); consoleSpy.debug = jest.spyOn(console, "debug").mockImplementation(); consoleSpy.error = jest.spyOn(console, "error").mockImplementation(); }); test("Test Successful Banner Fetch", async () => { - dynamoClientMock.on(GetCommand).resolves({ - Item: mockBannerResponse, - }); + (getBanner as jest.Mock).mockResolvedValueOnce(mockBannerResponse); const res = await fetchBanner(testEvent, null); expect(consoleSpy.debug).toHaveBeenCalled(); @@ -49,13 +47,11 @@ describe("Test fetchBanner API method", () => { }); test("Test successful empty banner found fetch", async () => { - dynamoClientMock.on(GetCommand).resolves({ - Item: undefined, - }); + (getBanner as jest.Mock).mockResolvedValueOnce(undefined); const res = await fetchBanner(testEvent, null); expect(consoleSpy.debug).toHaveBeenCalled(); - expect(res.body).not.toContain("testTitle"); + expect(res.body).not.toBeDefined(); expect(res.statusCode).toBe(StatusCodes.SUCCESS); }); diff --git a/services/app-api/handlers/banners/fetch.ts b/services/app-api/handlers/banners/fetch.ts index 3afb8e01d..5ea1ce093 100644 --- a/services/app-api/handlers/banners/fetch.ts +++ b/services/app-api/handlers/banners/fetch.ts @@ -1,22 +1,15 @@ import handler from "../handler-lib"; -import dynamoDb from "../../utils/dynamo/dynamodb-lib"; // types import { StatusCodes } from "../../utils/types"; // utils import { error } from "../../utils/constants/constants"; +import { getBanner } from "../../storage/banners"; export const fetchBanner = handler(async (event, _context) => { if (!event?.pathParameters?.bannerId!) { throw new Error(error.NO_KEY); } - const params = { - TableName: process.env.BANNER_TABLE_NAME!, - Key: { - key: event?.pathParameters?.bannerId!, - }, - }; - const response = await dynamoDb.get(params); - - const status = StatusCodes.SUCCESS; - return { status: status, body: response }; + const bannerId = event?.pathParameters?.bannerId!; + const banner = await getBanner(bannerId); + return { status: StatusCodes.SUCCESS, body: banner }; }); diff --git a/services/app-api/handlers/reports/create.test.ts b/services/app-api/handlers/reports/create.test.ts index ba89712a9..e07bc5a55 100644 --- a/services/app-api/handlers/reports/create.test.ts +++ b/services/app-api/handlers/reports/create.test.ts @@ -12,13 +12,18 @@ import { import { error } from "../../utils/constants/constants"; import * as authFunctions from "../../utils/auth/authorization"; import { getEligibleWorkPlan } from "../../utils/other/other"; -import { putReportMetadata, putReportFieldData } from "../../storage/reports"; +import { + queryReportMetadatasForState, + putReportMetadata, + putReportFieldData, +} from "../../storage/reports"; // types import { APIGatewayProxyEvent, StatusCodes } from "../../utils/types"; import { copyFieldDataFromSource } from "../../utils/other/copy"; import { getOrCreateFormTemplate } from "../../utils/formTemplates/formTemplates"; jest.mock("../../storage/reports", () => ({ + queryReportMetadatasForState: jest.fn(), putReportFieldData: jest.fn(), putReportMetadata: jest.fn(), })); @@ -66,7 +71,7 @@ const wpCreationEvent: APIGatewayProxyEvent = { metadata: { reportType: "WP", reportYear: 2020, - reportPeriod: 2, + reportPeriod: 1, submissionName: "submissionName", status: "Not started", lastAlteredBy: "Thelonious States", @@ -198,6 +203,9 @@ describe("Test createReport API method", () => { }); test("Test successful run of work plan report creation, not copied", async () => { + (queryReportMetadatasForState as jest.Mock).mockResolvedValue([ + { reportYear: 2020, reportPeriod: 1, archived: true }, + ]); const res = await createReport(wpCreationEvent, null); const body = JSON.parse(res.body); expect(consoleSpy.debug).toHaveBeenCalled(); @@ -209,12 +217,23 @@ describe("Test createReport API method", () => { mockWPReport.metadata.formTemplateId ); expect(body.reportYear).toEqual(2020); - expect(body.reportPeriod).toEqual(2); + expect(body.reportPeriod).toEqual(1); expect(putReportMetadata).toHaveBeenCalled(); expect(putReportFieldData).toHaveBeenCalled(); }); + test("Test work plan report creation returns 400 if report in year and period exists", async () => { + (queryReportMetadatasForState as jest.Mock).mockResolvedValue([ + { reportYear: 2020, reportPeriod: 1, archived: undefined }, + ]); + const res = await createReport(wpCreationEvent, null); + expect(res.statusCode).toBe(400); + }); + test("Test successful run of work plan report creation, copied", async () => { + (queryReportMetadatasForState as jest.Mock).mockResolvedValue([ + { reportYear: 2020, reportPeriod: 1, archived: undefined }, + ]); (copyFieldDataFromSource as jest.Mock).mockResolvedValue( mockReportFieldData ); @@ -245,6 +264,9 @@ describe("Test createReport API method", () => { workPlanMetadata: mockWPMetadata, workPlanFieldData: mockWPFieldData, }); + (queryReportMetadatasForState as jest.Mock).mockResolvedValue([ + { reportYear: 2020, reportPeriod: 1, archived: true }, + ]); const res = await createReport(sarCreationEvent, null); const body = JSON.parse(res.body); expect(consoleSpy.debug).toHaveBeenCalled(); diff --git a/services/app-api/handlers/reports/create.ts b/services/app-api/handlers/reports/create.ts index 9456f8abd..10b99152d 100644 --- a/services/app-api/handlers/reports/create.ts +++ b/services/app-api/handlers/reports/create.ts @@ -20,7 +20,11 @@ import { getReportPeriod, getReportYear, } from "../../utils/other/other"; -import { putReportFieldData, putReportMetadata } from "../../storage/reports"; +import { + putReportFieldData, + putReportMetadata, + queryReportMetadatasForState, +} from "../../storage/reports"; import { getOrCreateFormTemplate } from "../../utils/formTemplates/formTemplates"; import { logger } from "../../utils/debugging/debug-lib"; import { copyFieldDataFromSource } from "../../utils/other/copy"; @@ -110,9 +114,6 @@ export const createReport = handler( const reportYear = getReportYear(reportData, overrideCopyOver); const reportPeriod = getReportPeriod(reportData, overrideCopyOver); - // If this Work Plan is a reset, the reporting period is the upcoming one - const isReset = unvalidatedMetadata?.isReset; - // Begin Section - Getting/Creating newest Form Template based on reportType let formTemplate, formTemplateVersion; try { @@ -169,7 +170,7 @@ export const createReport = handler( generalInformation_resubmissionInformation: "N/A", }; - if (unvalidatedMetadata.copyReport && !isReset) { + if (unvalidatedMetadata.copyReport) { const reportPeriod = calculatePeriod(Date.now(), workPlanMetadata); const isCurrentPeriod = calculateCurrentYear() === unvalidatedMetadata.copyReport.reportYear && @@ -199,6 +200,39 @@ export const createReport = handler( * to create a different SAR and attach all of its fieldData to the SAR Submissions FieldData */ + // Validate the metadata for the submission + const validatedMetadata = await validateData(metadataValidationSchema, { + ...unvalidatedMetadata, + }); + + // Return INVALID_DATA error if metadata is not valid. + if (!validatedMetadata) { + return { + status: StatusCodes.BAD_REQUEST, + body: error.INVALID_DATA, + }; + } + + const existingReports: ReportMetadataShape[] = + await queryReportMetadatasForState(reportType, state); + + for (const report of existingReports) { + const { + reportYear: existingReportYear, + reportPeriod: existingReportPeriod, + archived, + } = report; + if ( + !archived && + existingReportYear === reportYear && + existingReportPeriod === reportPeriod + ) { + return { + status: StatusCodes.BAD_REQUEST, + body: error.INVALID_DATA, + }; + } + } const reportId: string = KSUID.randomSync().string; const fieldDataId: string = KSUID.randomSync().string; const formTemplateId: string = formTemplateVersion?.id; @@ -215,19 +249,6 @@ export const createReport = handler( }; } - // Validate the metadata for the submission - const validatedMetadata = await validateData(metadataValidationSchema, { - ...unvalidatedMetadata, - }); - - // Return INVALID_DATA error if metadata is not valid. - if (!validatedMetadata) { - return { - status: StatusCodes.BAD_REQUEST, - body: error.INVALID_DATA, - }; - } - // Begin Section - Create DyanmoDB record const createdReportMetadata: ReportMetadataShape = { ...validatedMetadata, diff --git a/services/app-api/handlers/templates/fetch.test.ts b/services/app-api/handlers/templates/fetch.test.ts index 80d56db2f..73815473f 100644 --- a/services/app-api/handlers/templates/fetch.test.ts +++ b/services/app-api/handlers/templates/fetch.test.ts @@ -9,8 +9,10 @@ jest.mock("../../utils/auth/authorization", () => ({ isAuthorized: jest.fn().mockReturnValue(true), })); -jest.mock("../../utils/s3/s3-lib", () => ({ - getSignedDownloadUrl: jest.fn().mockReturnValue("s3://fakeurl.bucket.here"), +jest.mock("../../storage/templates", () => ({ + getTemplateDownloadUrl: jest + .fn() + .mockResolvedValue("s3://fakeurl.bucket.here"), })); const testEvent: APIGatewayProxyEvent = { @@ -27,10 +29,6 @@ const consoleSpy: { }; describe("Test fetchTemplate API method", () => { - beforeAll(() => { - process.env["TEMPLATE_BUCKET"] = "fakeTestBucket"; - }); - test("Test Successful template url fetch with WP", async () => { const wpEvent: APIGatewayProxyEvent = { ...testEvent, diff --git a/services/app-api/handlers/templates/fetch.ts b/services/app-api/handlers/templates/fetch.ts index fc81e5f56..7f76596b4 100644 --- a/services/app-api/handlers/templates/fetch.ts +++ b/services/app-api/handlers/templates/fetch.ts @@ -1,26 +1,27 @@ import handler from "../handler-lib"; // utils import { error } from "../../utils/constants/constants"; -import s3Lib from "../../utils/s3/s3-lib"; +import { getTemplateDownloadUrl } from "../../storage/templates"; // types import { StatusCodes, TemplateKeys } from "../../utils/types"; +/* + * NOTE: This handler is not concerned with _form_ templates, like wp.json! + * + * It is exclusively for user-oriented help files: + * static downloads which users may refer to as "templates". + */ + export const fetchTemplate = handler(async (event, _context) => { if (!event?.pathParameters?.templateName!) { throw new Error(error.NO_TEMPLATE_NAME); } - let key; + let key: TemplateKeys | undefined; if (event.pathParameters.templateName === "WP") { key = TemplateKeys.WP; } else { throw new Error(error.INVALID_TEMPLATE_NAME); } - // get the signed URL string - const params = { - Bucket: process.env.TEMPLATE_BUCKET!, - Expires: 60, - Key: key, - }; - const url = await s3Lib.getSignedDownloadUrl(params); + const url = await getTemplateDownloadUrl(key); return { status: StatusCodes.SUCCESS, body: url }; }); diff --git a/services/app-api/package.json b/services/app-api/package.json index 50746da5e..2a9da2cf1 100644 --- a/services/app-api/package.json +++ b/services/app-api/package.json @@ -79,6 +79,7 @@ "collectCoverageFrom": [ "handlers/**/*.{ts,tsx}", "utils/**/*.{ts,tsx}", + "storage/**/*.{ts,tsx}", "!utils/constants/*", "!utils/testing/*", "!utils/types/*" diff --git a/services/app-api/storage/banners.test.ts b/services/app-api/storage/banners.test.ts new file mode 100644 index 000000000..df1ef0dfc --- /dev/null +++ b/services/app-api/storage/banners.test.ts @@ -0,0 +1,71 @@ +import { putBanner, getBanner, deleteBanner } from "./banners"; +import { mockClient } from "aws-sdk-client-mock"; +import { + DeleteCommand, + DynamoDBDocumentClient, + GetCommand, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; + +const mockDynamo = mockClient(DynamoDBDocumentClient); + +const mockBanner = { + key: "mock-key", + title: "Mock Title", + description: "Mock description", + startDate: new Date(2024, 8, 27).getDate(), + endDate: new Date(2024, 8, 28).getDate(), + isActive: true, +}; + +describe("Banner storage methods", () => { + beforeEach(() => { + mockDynamo.reset(); + }); + + it("should call Dynamo to create a new or updated banner", async () => { + const mockPut = jest.fn(); + mockDynamo.on(PutCommand).callsFakeOnce(mockPut); + + await putBanner(mockBanner); + + expect(mockPut).toHaveBeenCalledWith( + { + TableName: "local-banners", + Item: mockBanner, + }, + expect.any(Function) + ); + }); + + it("should call Dynamo to fetch a banner", async () => { + const mockFetch = jest.fn().mockResolvedValue({ Item: mockBanner }); + mockDynamo.on(GetCommand).callsFakeOnce(mockFetch); + + const banner = await getBanner("mock-key"); + + expect(banner).toBe(mockBanner); + expect(mockFetch).toHaveBeenCalledWith( + { + TableName: "local-banners", + Key: { key: "mock-key" }, + }, + expect.any(Function) + ); + }); + + it("should call Dynamo to delete a banner", async () => { + const mockDelete = jest.fn(); + mockDynamo.on(DeleteCommand).callsFakeOnce(mockDelete); + + await deleteBanner("mock-key"); + + expect(mockDelete).toHaveBeenCalledWith( + { + TableName: "local-banners", + Key: { key: "mock-key" }, + }, + expect.any(Function) + ); + }); +}); diff --git a/services/app-api/storage/banners.ts b/services/app-api/storage/banners.ts new file mode 100644 index 000000000..846579ea9 --- /dev/null +++ b/services/app-api/storage/banners.ts @@ -0,0 +1,38 @@ +import { DeleteCommand, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { createClient } from "./dynamodb-lib"; +import { AdminBannerData } from "../utils/types/banner"; + +const bannerTableName = process.env.BANNER_TABLE_NAME!; +const client = createClient(); + +export const putBanner = async (banner: AdminBannerData) => { + await client.send( + new PutCommand({ + TableName: bannerTableName, + Item: banner, + }) + ); +}; + +export const getBanner = async (bannerId: string) => { + const response = await client.send( + new GetCommand({ + TableName: bannerTableName, + Key: { + key: bannerId, + }, + }) + ); + return response.Item as AdminBannerData | undefined; +}; + +export const deleteBanner = async (bannerId: string) => { + await client.send( + new DeleteCommand({ + TableName: bannerTableName, + Key: { + key: bannerId, + }, + }) + ); +}; diff --git a/services/app-api/storage/dynamodb-lib.test.ts b/services/app-api/storage/dynamodb-lib.test.ts new file mode 100644 index 000000000..f9c8b5596 --- /dev/null +++ b/services/app-api/storage/dynamodb-lib.test.ts @@ -0,0 +1,46 @@ +import { collectPageItems, createClient } from "./dynamodb-lib"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + +describe("createClient", () => { + let originalUrl: string | undefined; + beforeAll(() => { + originalUrl = process.env.DYNAMODB_URL; + }); + afterAll(() => { + process.env.DYNAMODB_URL = originalUrl; + }); + + const getRegion = async (client: DynamoDBDocumentClient) => { + const configValue = client.config.region; + if (typeof configValue === "string") { + return configValue; + } else { + return await configValue(); + } + }; + + it("should return a local client if DYNAMODB_URL is set", async () => { + process.env.DYNAMODB_URL = "mock url"; + const client = createClient(); + const region = await getRegion(client); + expect(region).toBe("localhost"); + }); + + it("should return a live client if DYNAMODB_URL is undefined", async () => { + delete process.env.DYNAMODB_URL; + const client = createClient(); + const region = await getRegion(client); + expect(region).toBe("us-east-1"); + }); +}); + +describe("collectPageItems", () => { + it("should combine items from multiple pages into one array", async () => { + const mockPaginator = (async function* () { + yield { Items: [{ foo: "bar" }] }; + yield { Items: [{ foo: "baz" }] }; + })() as any; + const allItems = await collectPageItems(mockPaginator); + expect(allItems).toEqual([{ foo: "bar" }, { foo: "baz" }]); + }); +}); diff --git a/services/app-api/storage/dynamodb-lib.ts b/services/app-api/storage/dynamodb-lib.ts new file mode 100644 index 000000000..7d7aea0e5 --- /dev/null +++ b/services/app-api/storage/dynamodb-lib.ts @@ -0,0 +1,43 @@ +import { + DynamoDBClient, + QueryCommandOutput, + ScanCommandOutput, +} from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, Paginator } from "@aws-sdk/lib-dynamodb"; +// utils +import { logger } from "../utils/debugging/debug-lib"; + +const localConfig = { + endpoint: process.env.DYNAMODB_URL, + region: "localhost", + credentials: { + accessKeyId: "LOCALFAKEKEY", // pragma: allowlist secret + secretAccessKey: "LOCALFAKESECRET", // pragma: allowlist secret + }, + logger, +}; + +const awsConfig = { + region: "us-east-1", + logger, +}; + +const getConfig = () => { + return process.env.DYNAMODB_URL ? localConfig : awsConfig; +}; + +export const createClient = () => { + return DynamoDBDocumentClient.from(new DynamoDBClient(getConfig())); +}; + +export const collectPageItems = async < + T extends QueryCommandOutput | ScanCommandOutput +>( + paginator: Paginator +) => { + let items: Record = []; + for await (let page of paginator) { + items = items.concat(page.Items ?? []); + } + return items; +}; diff --git a/services/app-api/storage/reports.ts b/services/app-api/storage/reports.ts index cb70eafae..be5cdf584 100644 --- a/services/app-api/storage/reports.ts +++ b/services/app-api/storage/reports.ts @@ -16,11 +16,8 @@ import { import { createClient as createDynamoClient, collectPageItems, -} from "../utils/dynamo/dynamodb-lib"; -import { - createClient as createS3Client, - parseS3Response, -} from "../utils/s3/s3-lib"; +} from "./dynamodb-lib"; +import { createClient as createS3Client, parseS3Response } from "./s3-lib"; import { reportBuckets, reportTables } from "../utils/constants/constants"; const dynamoClient = createDynamoClient(); diff --git a/services/app-api/storage/s3-lib.test.ts b/services/app-api/storage/s3-lib.test.ts new file mode 100644 index 000000000..f2ff03f42 --- /dev/null +++ b/services/app-api/storage/s3-lib.test.ts @@ -0,0 +1,67 @@ +import { createClient, parseS3Response } from "./s3-lib"; +import { GetObjectCommandOutput, S3Client } from "@aws-sdk/client-s3"; + +describe("S3 helper functions", () => { + describe("createClient", () => { + let originalEndpoint: string | undefined; + beforeAll(() => { + originalEndpoint = process.env.S3_LOCAL_ENDPOINT; + }); + afterAll(() => { + process.env.S3_LOCAL_ENDPOINT = originalEndpoint; + }); + + const getRegion = async (client: S3Client) => { + const configValue = client.config.region; + if (typeof configValue === "string") { + return configValue; + } else { + return await configValue(); + } + }; + + it("should return a local client if S3_LOCAL_ENDPOINT is set", async () => { + process.env.S3_LOCAL_ENDPOINT = "mock endpoint"; + const client = createClient(); + const region = await getRegion(client); + expect(region).toBe("localhost"); + }); + + test("should return a live client if S3_LOCAL_ENDPOINT is undefined", async () => { + delete process.env.S3_LOCAL_ENDPOINT; + const client = createClient(); + const region = await getRegion(client); + expect(region).toBe("us-east-1"); + }); + }); + + describe("parseS3Response", () => { + it("should return undefined for missing objects", async () => { + const response = { + Body: undefined, + } as GetObjectCommandOutput; + const parsed = await parseS3Response(response); + expect(parsed).toBeUndefined(); + }); + + it("should return undefined for non-string objects", async () => { + const response = { + Body: { + transformToString: jest.fn().mockResolvedValue(""), + } as any, + } as GetObjectCommandOutput; + const parsed = await parseS3Response(response); + expect(parsed).toBeUndefined(); + }); + + it("should parse JSON objects it finds", async () => { + const response = { + Body: { + transformToString: jest.fn().mockResolvedValue(`{"foo":"bar"}`), + } as any, + } as GetObjectCommandOutput; + const parsed = await parseS3Response(response); + expect(parsed).toEqual({ foo: "bar" }); + }); + }); +}); diff --git a/services/app-api/storage/s3-lib.ts b/services/app-api/storage/s3-lib.ts new file mode 100644 index 000000000..4d8b01003 --- /dev/null +++ b/services/app-api/storage/s3-lib.ts @@ -0,0 +1,33 @@ +import { S3Client, GetObjectCommandOutput } from "@aws-sdk/client-s3"; +import { logger } from "../utils/debugging/debug-lib"; + +const localConfig = { + endpoint: process.env.S3_LOCAL_ENDPOINT, + region: "localhost", + forcePathStyle: true, + credentials: { + accessKeyId: "S3RVER", // pragma: allowlist secret + secretAccessKey: "S3RVER", // pragma: allowlist secret + }, + logger, +}; + +const awsConfig = { + region: "us-east-1", + logger, +}; + +const getConfig = () => { + return process.env.S3_LOCAL_ENDPOINT ? localConfig : awsConfig; +}; + +export const createClient = () => new S3Client(getConfig()); + +export const parseS3Response = async (response: GetObjectCommandOutput) => { + const stringBody = await response.Body?.transformToString(); + if (!stringBody) { + logger.warn(`Empty response from S3`); + return undefined; + } + return JSON.parse(stringBody); +}; diff --git a/services/app-api/storage/templates.test.ts b/services/app-api/storage/templates.test.ts new file mode 100644 index 000000000..7c8969d4e --- /dev/null +++ b/services/app-api/storage/templates.test.ts @@ -0,0 +1,33 @@ +import { getTemplateDownloadUrl } from "./templates"; +import { TemplateKeys } from "../utils/types"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; + +jest.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: jest.fn(), +})); + +describe("Template storage methods", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call S3 to get a pre-signed file download URL", async () => { + (getSignedUrl as jest.Mock).mockResolvedValueOnce("https://example.com"); + + const result = await getTemplateDownloadUrl( + "mock-template.pdf" as TemplateKeys + ); + + expect(result).toBe("https://example.com"); + const parameters = (getSignedUrl as jest.Mock).mock.calls[0]; + const [client, command, options] = parameters; + expect(client).toBeInstanceOf(S3Client); + expect(command).toBeInstanceOf(GetObjectCommand); + expect(command.input).toEqual({ + Bucket: "local-templates", + Key: "mock-template.pdf", + }); + expect(options).toEqual({ expiresIn: 3600 }); + }); +}); diff --git a/services/app-api/storage/templates.ts b/services/app-api/storage/templates.ts new file mode 100644 index 000000000..50218236f --- /dev/null +++ b/services/app-api/storage/templates.ts @@ -0,0 +1,20 @@ +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { createClient } from "./s3-lib"; +import { TemplateKeys } from "../utils/types"; + +const client = createClient(); + +export const getTemplateDownloadUrl = async (templateKey: TemplateKeys) => { + return await getSignedUrl( + client, + new GetObjectCommand({ + Bucket: process.env.TEMPLATE_BUCKET!, + Key: templateKey, + }), + { + // 3600 seconds = 1 hour + expiresIn: 3600, + } + ); +}; diff --git a/services/app-api/utils/dynamo/dynamodb-lib.test.ts b/services/app-api/utils/dynamo/dynamodb-lib.test.ts deleted file mode 100644 index aa20a14e4..000000000 --- a/services/app-api/utils/dynamo/dynamodb-lib.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import dynamoLib, { collectPageItems, createClient } from "./dynamodb-lib"; -import { - DeleteCommand, - DynamoDBDocumentClient, - GetCommand, - PutCommand, - QueryCommand, -} from "@aws-sdk/lib-dynamodb"; -import { mockClient } from "aws-sdk-client-mock"; - -const dynamoClientMock = mockClient(DynamoDBDocumentClient); - -describe("Test DynamoDB Interaction API Build Structure", () => { - let originalUrl: string | undefined; - beforeAll(() => { - originalUrl = process.env.DYNAMODB_URL; - }); - afterAll(() => { - process.env.DYNAMODB_URL = originalUrl; - }); - beforeEach(() => { - dynamoClientMock.reset(); - }); - test("Can query all", async () => { - const mockItem1 = { foo: "bar" }; - const mockItem2 = { foo: "baz" }; - dynamoClientMock - .on(QueryCommand) - .resolvesOnce({ Items: [mockItem1], LastEvaluatedKey: mockItem1 }) - .resolvesOnce({ Items: [mockItem2] }); - - const result = await dynamoLib.query({ TableName: "foos" }); - - expect(result).toHaveLength(2); - expect(result[0]).toBe(mockItem1); - expect(result[1]).toBe(mockItem2); - }); - test("Can get", async () => { - const mockGet = jest.fn(); - dynamoClientMock.on(GetCommand).callsFake(mockGet); - - await dynamoLib.get({ TableName: "foos", Key: { id: "foo1" } }); - - expect(mockGet).toHaveBeenCalled(); - }); - test("Can put", async () => { - const mockPut = jest.fn(); - dynamoClientMock.on(PutCommand).callsFake(mockPut); - - await dynamoLib.put({ TableName: "foos", Item: { id: "foo1" } }); - - expect(mockPut).toHaveBeenCalled(); - }); - test("Can delete", async () => { - const mockDelete = jest.fn(); - dynamoClientMock.on(DeleteCommand).callsFake(mockDelete); - - await dynamoLib.delete({ TableName: "foos", Key: { id: "fid" } }); - - expect(mockDelete).toHaveBeenCalled(); - }); -}); - -describe("createClient", () => { - let originalUrl: string | undefined; - beforeAll(() => { - originalUrl = process.env.DYNAMODB_URL; - }); - afterAll(() => { - process.env.DYNAMODB_URL = originalUrl; - }); - - const getRegion = async (client: DynamoDBDocumentClient) => { - const configValue = client.config.region; - if (typeof configValue === "string") { - return configValue; - } else { - return await configValue(); - } - }; - - it("should return a local client if DYNAMODB_URL is set", async () => { - process.env.DYNAMODB_URL = "mock url"; - const client = createClient(); - const region = await getRegion(client); - expect(region).toBe("localhost"); - }); - - it("should return a live client if DYNAMODB_URL is undefined", async () => { - delete process.env.DYNAMODB_URL; - const client = createClient(); - const region = await getRegion(client); - expect(region).toBe("us-east-1"); - }); -}); - -describe("collectPageItems", () => { - it("should combine items from multiple pages into one array", async () => { - const mockPaginator = (async function* () { - yield { Items: [{ foo: "bar" }] }; - yield { Items: [{ foo: "baz" }] }; - })() as any; - const allItems = await collectPageItems(mockPaginator); - expect(allItems).toEqual([{ foo: "bar" }, { foo: "baz" }]); - }); -}); diff --git a/services/app-api/utils/dynamo/dynamodb-lib.ts b/services/app-api/utils/dynamo/dynamodb-lib.ts deleted file mode 100644 index 42ea12cba..000000000 --- a/services/app-api/utils/dynamo/dynamodb-lib.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - DynamoDBClient, - QueryCommandOutput, - ScanCommandOutput, -} from "@aws-sdk/client-dynamodb"; -import { - DeleteCommand, - DeleteCommandInput, - DynamoDBDocumentClient, - GetCommand, - GetCommandInput, - QueryCommandInput, - paginateQuery, - PutCommand, - PutCommandInput, - Paginator, -} from "@aws-sdk/lib-dynamodb"; -// utils -import { logger } from "../debugging/debug-lib"; -// types -import { AnyObject } from "../types"; - -const localConfig = { - endpoint: process.env.DYNAMODB_URL, - region: "localhost", - credentials: { - accessKeyId: "LOCALFAKEKEY", // pragma: allowlist secret - secretAccessKey: "LOCALFAKESECRET", // pragma: allowlist secret - }, - logger, -}; - -const awsConfig = { - region: "us-east-1", - logger, -}; - -const getConfig = () => { - return process.env.DYNAMODB_URL ? localConfig : awsConfig; -}; - -export const createClient = () => { - return DynamoDBDocumentClient.from(new DynamoDBClient(getConfig())); -}; - -export const collectPageItems = async < - T extends QueryCommandOutput | ScanCommandOutput ->( - paginator: Paginator -) => { - let items: Record = []; - for await (let page of paginator) { - items = items.concat(page.Items ?? []); - } - return items; -}; - -const client = createClient(); - -export default { - get: async (params: GetCommandInput) => { - return await client.send(new GetCommand(params)); - }, - delete: async (params: DeleteCommandInput) => - await client.send(new DeleteCommand(params)), - put: async (params: PutCommandInput) => - await client.send(new PutCommand(params)), - query: async (params: QueryCommandInput) => { - let items: AnyObject[] = []; - const resultPages = paginateQuery({ client }, params); - for await (let page of resultPages) { - items = items.concat(page?.Items ?? []); - } - - return items; - }, -}; diff --git a/services/app-api/utils/kafka/kafka-source-lib.test.ts b/services/app-api/utils/kafka/kafka-source-lib.test.ts index c071f0b8e..bf57dccda 100644 --- a/services/app-api/utils/kafka/kafka-source-lib.test.ts +++ b/services/app-api/utils/kafka/kafka-source-lib.test.ts @@ -1,5 +1,8 @@ import KafkaSourceLib from "./kafka-source-lib"; -import s3Lib from "../s3/s3-lib"; +import { mockClient } from "aws-sdk-client-mock"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; + +const mockS3 = mockClient(S3Client); let tempStage: string | undefined; let tempNamespace: string | undefined; @@ -172,12 +175,16 @@ describe("Test Kafka Lib", () => { }); test("Processes bucket events", async () => { - const s3GetSpy = jest.spyOn(s3Lib, "get"); - s3GetSpy.mockResolvedValue("response object"); + const mockS3Get = jest.fn().mockResolvedValueOnce({ + Body: { + transformToString: jest.fn().mockResolvedValueOnce("response object"), + }, + }); + mockS3.on(GetObjectCommand).callsFakeOnce(mockS3Get); const sourceLib = new KafkaSourceLib("mfp", "v0", [table], [bucket]); await sourceLib.handler(s3Event); expect(consoleSpy.log).toHaveBeenCalled(); - expect(s3GetSpy).toHaveBeenCalled(); + expect(mockS3Get).toHaveBeenCalled(); expect(mockSendBatch).toBeCalledTimes(1); }); diff --git a/services/app-api/utils/kafka/kafka-source-lib.ts b/services/app-api/utils/kafka/kafka-source-lib.ts index 18a19e00d..bd3d60f6f 100644 --- a/services/app-api/utils/kafka/kafka-source-lib.ts +++ b/services/app-api/utils/kafka/kafka-source-lib.ts @@ -1,7 +1,8 @@ /* eslint-disable no-console */ +import { GetObjectCommand } from "@aws-sdk/client-s3"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import s3Lib from "../s3/s3-lib"; import { Kafka, Producer } from "kafkajs"; +import { createClient } from "../../storage/s3-lib"; import { S3EventRecord } from "../types"; type KafkaPayload = { @@ -143,11 +144,21 @@ class KafkaSourceLib { const { eventName, eventTime } = record; let entry = ""; if (!eventName.includes("ObjectRemoved")) { - const s3Doc = await s3Lib.get({ - Bucket: record.s3.bucket.name, - Key: record.s3.object.key, - }); - entry = this.stringify(s3Doc); + const client = createClient(); + const response = await client.send( + new GetObjectCommand({ + Bucket: record.s3.bucket.name, + Key: record.s3.object.key, + }) + ); + const responseString = await response.Body?.transformToString(); + if (responseString) { + entry = responseString; + } else { + throw new Error( + `Failed to fetch S3 object with key: "${record.s3.object.key}"` + ); + } } return { diff --git a/services/app-api/utils/other/other.test.ts b/services/app-api/utils/other/other.test.ts index e18b207a9..0db6ca323 100644 --- a/services/app-api/utils/other/other.test.ts +++ b/services/app-api/utils/other/other.test.ts @@ -32,7 +32,6 @@ const mockUnvalidatedMetadata = { createdAt: 1699496172798, lastAlteredBy: "Anthony Soprano", locked: false, - isReset: false, }; describe("API utility functions", () => { diff --git a/services/app-api/utils/s3/s3-lib.test.ts b/services/app-api/utils/s3/s3-lib.test.ts deleted file mode 100644 index 00efa33e3..000000000 --- a/services/app-api/utils/s3/s3-lib.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import s3Lib, { createClient, parseS3Response } from "./s3-lib"; -import { - GetObjectCommand, - GetObjectCommandOutput, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { mockClient } from "aws-sdk-client-mock"; - -const s3ClientMock = mockClient(S3Client); - -jest.mock("@aws-sdk/s3-request-presigner", () => ({ - getSignedUrl: jest.fn().mockResolvedValue("mock signed url"), -})); - -describe("Test s3Lib Interaction API Build Structure", () => { - let originalEndpoint: string | undefined; - beforeAll(() => { - originalEndpoint = process.env.S3_LOCAL_ENDPOINT; - }); - afterAll(() => { - process.env.S3_LOCAL_ENDPOINT = originalEndpoint; - }); - - beforeEach(() => { - jest.clearAllMocks(); - s3ClientMock.reset(); - }); - - test("Can get object", async () => { - s3ClientMock.on(GetObjectCommand).resolves({ - Body: { - transformToString: () => Promise.resolve(`{"json":"blob"}`), - }, - } as GetObjectCommandOutput); - - const result = await s3Lib.get({ Bucket: "b", Key: "k" }); - - expect(result).toEqual({ json: "blob" }); - }); - - test("Can put object", async () => { - const mockPut = jest.fn(); - s3ClientMock.on(PutObjectCommand).callsFake(mockPut); - - await s3Lib.put({ Bucket: "b", Key: "k", Body: "body" }); - - expect(mockPut).toHaveBeenCalled(); - }); - - test("Can create presigned download URL", async () => { - process.env.S3_LOCAL_ENDPOINT = "mock endpoint"; - const url = await s3Lib.getSignedDownloadUrl({ Bucket: "b", Key: "k" }); - - expect(url).toBe("mock signed url"); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_client, command] = (getSignedUrl as jest.Mock).mock.calls[0]; - expect(command).toBeInstanceOf(GetObjectCommand); - }); -}); - -describe("createClient", () => { - let originalEndpoint: string | undefined; - beforeAll(() => { - originalEndpoint = process.env.S3_LOCAL_ENDPOINT; - }); - afterAll(() => { - process.env.S3_LOCAL_ENDPOINT = originalEndpoint; - }); - - const getRegion = async (client: S3Client) => { - const configValue = client.config.region; - if (typeof configValue === "string") { - return configValue; - } else { - return await configValue(); - } - }; - - it("should return a local client if S3_LOCAL_ENDPOINT is set", async () => { - process.env.S3_LOCAL_ENDPOINT = "mock endpoint"; - const client = createClient(); - const region = await getRegion(client); - expect(region).toBe("localhost"); - }); - - test("should return a live client if S3_LOCAL_ENDPOINT is undefined", async () => { - delete process.env.S3_LOCAL_ENDPOINT; - const client = createClient(); - const region = await getRegion(client); - expect(region).toBe("us-east-1"); - }); -}); - -describe("parseS3Response", () => { - it("should return undefined for missing objects", async () => { - const response = { - Body: undefined, - } as GetObjectCommandOutput; - const parsed = await parseS3Response(response); - expect(parsed).toBeUndefined(); - }); - - it("should return undefined for non-string objects", async () => { - const response = { - Body: { - transformToString: jest.fn().mockResolvedValue(""), - } as any, - } as GetObjectCommandOutput; - const parsed = await parseS3Response(response); - expect(parsed).toBeUndefined(); - }); - - it("should parse JSON objects it finds", async () => { - const response = { - Body: { - transformToString: jest.fn().mockResolvedValue(`{"foo":"bar"}`), - } as any, - } as GetObjectCommandOutput; - const parsed = await parseS3Response(response); - expect(parsed).toEqual({ foo: "bar" }); - }); -}); diff --git a/services/app-api/utils/s3/s3-lib.ts b/services/app-api/utils/s3/s3-lib.ts deleted file mode 100644 index b3c0691da..000000000 --- a/services/app-api/utils/s3/s3-lib.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - S3Client, - PutObjectCommand, - PutObjectCommandInput, - GetObjectCommandInput, - GetObjectCommand, - GetObjectRequest, - GetObjectCommandOutput, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { logger } from "../debugging/debug-lib"; -import { buckets, error } from "../constants/constants"; -import { State } from "../types"; - -const localConfig = { - endpoint: process.env.S3_LOCAL_ENDPOINT, - region: "localhost", - forcePathStyle: true, - credentials: { - accessKeyId: "S3RVER", // pragma: allowlist secret - secretAccessKey: "S3RVER", // pragma: allowlist secret - }, - logger, -}; - -const awsConfig = { - region: "us-east-1", - logger, -}; - -const getConfig = () => { - return process.env.S3_LOCAL_ENDPOINT ? localConfig : awsConfig; -}; - -export const createClient = () => new S3Client(getConfig()); - -export const parseS3Response = async (response: GetObjectCommandOutput) => { - const stringBody = await response.Body?.transformToString(); - if (!stringBody) { - logger.warn(`Empty response from S3`); - return undefined; - } - return JSON.parse(stringBody); -}; - -const client = createClient(); - -export default { - put: async (params: PutObjectCommandInput) => - await client.send(new PutObjectCommand(params)), - get: async (params: GetObjectCommandInput) => { - try { - const response = await client.send(new GetObjectCommand(params)); - const stringBody = await response.Body?.transformToString(); - if (stringBody) { - return JSON.parse(stringBody); - } else { - throw new Error(); - } - } catch { - throw new Error(error.S3_OBJECT_GET_ERROR); - } - }, - getSignedDownloadUrl: async (params: GetObjectRequest) => { - return await getSignedUrl(client, new GetObjectCommand(params), { - expiresIn: 3600, - }); - }, -}; - -export function getFieldDataKey(state: State, fieldDataId: string) { - return `${buckets.FIELD_DATA}/${state}/${fieldDataId}.json`; -} - -export function getFormTemplateKey(formTemplateId: string) { - return `${buckets.FORM_TEMPLATE}/${formTemplateId}.json`; -} diff --git a/services/app-api/utils/testing/setupJest.ts b/services/app-api/utils/testing/setupJest.ts index c1d72e6bc..6b9bab3c4 100644 --- a/services/app-api/utils/testing/setupJest.ts +++ b/services/app-api/utils/testing/setupJest.ts @@ -1,6 +1,6 @@ /* - * These env vars are only used by storage/reports.test.ts, - * But they must be set before storage/reports.ts is loaded, + * These env vars are only used by storage/*.test.ts, + * But they must be set before storage/*.ts is loaded, * So they live here in setupJest! */ process.env.WP_REPORT_TABLE_NAME = "local-wp-reports"; @@ -8,6 +8,8 @@ process.env.SAR_REPORT_TABLE_NAME = "local-sar-reports"; process.env.WP_FORM_BUCKET = "database-local-wp"; process.env.SAR_FORM_BUCKET = "database-local-sar"; process.env.FORM_TEMPLATE_TABLE_NAME = "local-form-template-versions"; +process.env.BANNER_TABLE_NAME = "local-banners"; +process.env.TEMPLATE_BUCKET = "local-templates"; export const mockReportFieldData = { text: "text-input", diff --git a/services/app-api/utils/types/banner.ts b/services/app-api/utils/types/banner.ts new file mode 100644 index 000000000..a0fcbaf97 --- /dev/null +++ b/services/app-api/utils/types/banner.ts @@ -0,0 +1,13 @@ +export interface BannerData { + title: string; + description: string; + link?: string; + [key: string]: any; +} + +export interface AdminBannerData extends BannerData { + key: string; + startDate: number; + endDate: number; + isActive?: boolean; +} diff --git a/services/ui-src/package.json b/services/ui-src/package.json index 686698dd1..559ab7245 100644 --- a/services/ui-src/package.json +++ b/services/ui-src/package.json @@ -17,7 +17,7 @@ "@chakra-ui/react": "^1.8.9", "@cmsgov/design-system": "^3.8.0", "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.5", + "@emotion/styled": "^11.13.0", "@hookform/resolvers": "^2.9.11", "@vitejs/plugin-react": "^4.3.0", "aws-amplify": "^5.3.19", diff --git a/services/ui-src/src/components/banners/AdminBannerProvider.tsx b/services/ui-src/src/components/banners/AdminBannerProvider.tsx index 876bb55b2..1dd18a56a 100644 --- a/services/ui-src/src/components/banners/AdminBannerProvider.tsx +++ b/services/ui-src/src/components/banners/AdminBannerProvider.tsx @@ -39,7 +39,7 @@ export const AdminBannerProvider = ({ children }: Props) => { setBannerLoading(true); try { const currentBanner = await getBanner(ADMIN_BANNER_ID); - const newBannerData = currentBanner?.Item || {}; + const newBannerData = currentBanner as AdminBannerData | undefined; setBannerData(newBannerData); setBannerErrorMessage(undefined); } catch (e: any) { diff --git a/services/ui-src/src/components/cards/EntityStepCard.tsx b/services/ui-src/src/components/cards/EntityStepCard.tsx index 49683a422..49fb9cda2 100644 --- a/services/ui-src/src/components/cards/EntityStepCard.tsx +++ b/services/ui-src/src/components/cards/EntityStepCard.tsx @@ -10,7 +10,6 @@ import { ReportType, } from "types"; // assets -import { svgFilters } from "styles/theme"; import completedIcon from "assets/icons/icon_check_circle.png"; import deleteIcon from "assets/icons/icon_cancel_x_circle.png"; import editIcon from "assets/icons/icon_edit.png"; @@ -19,6 +18,7 @@ import { fillEmptyQuarters, useStore } from "utils"; import { ObjectiveProgressEntity } from "./ObjectiveProgressEntity"; import { EvaluationPlanEntity } from "./EvaluationPlanEntity"; import { FundingSourcesEntity } from "./FundingSourcesEntity"; +import { svgFilters } from "styles/foundations/filters"; export const EntityStepCard = ({ entity, diff --git a/services/ui-src/src/components/cards/TemplateCard.tsx b/services/ui-src/src/components/cards/TemplateCard.tsx index 3142d5fa4..de02c3fba 100644 --- a/services/ui-src/src/components/cards/TemplateCard.tsx +++ b/services/ui-src/src/components/cards/TemplateCard.tsx @@ -55,7 +55,7 @@ export const TemplateCard = ({ {verbiage.downloadText && ( - {!isCopyDisabled() && ( - - )} - + {showAlert && ( + + )} + {!notCopyingReport && ( + )} ); }; interface Props { + isResetting: boolean; modalDisclosure: { isOpen: boolean; onClose: any; @@ -216,11 +253,4 @@ const sx = { fontSize: "sm", }, }, - resetBtn: { - border: "none", - marginTop: "1rem", - fontWeight: "none", - textDecoration: "underline", - fontSize: "0.875rem", - }, }; diff --git a/services/ui-src/src/components/modals/Modal.tsx b/services/ui-src/src/components/modals/Modal.tsx index 700d9e44a..fdc2d6f90 100644 --- a/services/ui-src/src/components/modals/Modal.tsx +++ b/services/ui-src/src/components/modals/Modal.tsx @@ -47,7 +47,7 @@ export const Modal = ({ + {previousReport && ( + + )} )}