diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index f45adc9..01dff0d 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -37,7 +37,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -80,7 +80,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 2b1b2f8..85480ff 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -37,7 +37,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -80,7 +80,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -109,7 +109,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -152,7 +152,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 1112fc3..5edf7e3 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -120,14 +120,34 @@ Resources: Type: AWS::Serverless::Function DependsOn: - AppLogGroups + Metadata: + BuildMethod: esbuild + BuildProperties: + Format: esm + Minify: true + OutExtension: + - .js=.mjs + Target: "es2022" + Sourcemap: false + EntryPoints: + - api/lambda.js + External: + - aws-sdk + Banner: + - js=import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); Properties: Architectures: [arm64] CodeUri: ../dist AutoPublishAlias: live - Runtime: nodejs20.x + Runtime: nodejs22.x Description: !Sub "${ApplicationFriendlyName} API Lambda" FunctionName: !Sub ${ApplicationPrefix}-lambda - Handler: api/lambda.handler + Handler: lambda.handler MemorySize: 512 Role: !GetAtt AppSecurityRoles.Outputs.MainFunctionRoleArn Timeout: 60 diff --git a/package.json b/package.json index 5b5825c..ff230ff 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "yarn workspaces run build && yarn lockfile-manage", "dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", - "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp package.json dist/ && rm package-lock.json", + "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp src/api/package.json dist/ && rm package-lock.json", "prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts", "prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts", "lint": "yarn workspaces run lint", @@ -25,35 +25,11 @@ "test:e2e": "playwright test", "test:e2e-ui": "playwright test --ui" }, - "dependencies": { - "@aws-sdk/client-dynamodb": "^3.624.0", - "@aws-sdk/client-secrets-manager": "^3.624.0", - "@aws-sdk/util-dynamodb": "^3.624.0", - "@azure/msal-node": "^2.16.1", - "@fastify/auth": "^5.0.1", - "@fastify/aws-lambda": "^5.0.0", - "@fastify/caching": "^9.0.1", - "@fastify/cors": "^10.0.1", - "@touch4it/ical-timezones": "^1.9.0", - "discord.js": "^14.15.3", - "dotenv": "^16.4.5", - "fastify": "^5.1.0", - "fastify-plugin": "^4.5.1", - "ical-generator": "^7.2.0", - "jsonwebtoken": "^9.0.2", - "jwks-rsa": "^3.1.0", - "moment": "^2.30.1", - "moment-timezone": "^0.5.45", - "node-cache": "^5.1.2", - "pluralize": "^8.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.2", - "zod-validation-error": "^3.3.1" - }, + "dependencies": {}, "devDependencies": { "@eslint/compat": "^1.1.1", "@playwright/test": "^1.49.1", - "@tsconfig/node20": "^20.1.4", + "@tsconfig/node22": "^22.0.0", "@types/node": "^22.1.0", "@types/pluralize": "^0.0.33", "@types/react": "^18.3.3", @@ -82,7 +58,7 @@ "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", "jsdom": "^24.1.1", - "node-ical": "^0.18.0", + "node-ical": "^0.20.1", "postcss": "^8.4.41", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", diff --git a/src/api/functions/cache.ts b/src/api/functions/cache.ts index 5d007e7..6275988 100644 --- a/src/api/functions/cache.ts +++ b/src/api/functions/cache.ts @@ -6,11 +6,8 @@ import { import { genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - export async function getItemFromCache( + dynamoClient: DynamoDBClient, key: string, ): Promise> { const currentTime = Math.floor(Date.now() / 1000); @@ -37,6 +34,7 @@ export async function getItemFromCache( } export async function insertItemIntoCache( + dynamoClient: DynamoDBClient, key: string, value: Record, expireAt: Date, diff --git a/src/api/functions/discord.ts b/src/api/functions/discord.ts index 51ed648..5fb9268 100644 --- a/src/api/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -15,6 +15,7 @@ import { FastifyBaseLogger } from "fastify"; import { DiscordEventError } from "../../common/errors/index.js"; import { getSecretValue } from "../plugins/auth.js"; import { genericConfig } from "../../common/config.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; // https://stackoverflow.com/a/3809435/5684541 // https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30 @@ -24,12 +25,13 @@ export type IUpdateDiscord = EventPostRequest & { id: string }; const urlRegex = /https:\/\/[a-z0-9\.-]+\/calendar\?id=([a-f0-9-]+)/; export const updateDiscord = async ( + smClient: SecretsManagerClient, event: IUpdateDiscord, isDelete: boolean = false, logger: FastifyBaseLogger, ): Promise => { const secretApiConfig = - (await getSecretValue(genericConfig.ConfigSecretName)) || {}; + (await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {}; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let payload: GuildScheduledEventCreateOptions | null = null; diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 4c5b48d..30c0331 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -18,6 +18,7 @@ import { EntraGroupActions, EntraInvitationResponse, } from "../../common/types/iam.js"; +import { FastifyInstance } from "fastify"; function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed @@ -25,11 +26,15 @@ function validateGroupId(groupId: string): boolean { } export async function getEntraIdToken( + fastify: FastifyInstance, clientId: string, scopes: string[] = ["https://graph.microsoft.com/.default"], ) { const secretApiConfig = - (await getSecretValue(genericConfig.ConfigSecretName)) || {}; + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || {}; if ( !secretApiConfig.entra_id_private_key || !secretApiConfig.entra_id_thumbprint @@ -42,7 +47,10 @@ export async function getEntraIdToken( secretApiConfig.entra_id_private_key as string, "base64", ).toString("utf8"); - const cachedToken = await getItemFromCache("entra_id_access_token"); + const cachedToken = await getItemFromCache( + fastify.dynamoClient, + "entra_id_access_token", + ); if (cachedToken) { return cachedToken["token"] as string; } @@ -70,6 +78,7 @@ export async function getEntraIdToken( date.setTime(date.getTime() - 30000); if (result?.accessToken) { await insertItemIntoCache( + fastify.dynamoClient, "entra_id_access_token", { token: result?.accessToken }, date, diff --git a/src/api/index.ts b/src/api/index.ts index 4fc737f..ed7f349 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,12 +19,22 @@ import iamRoutes from "./routes/iam.js"; import ticketsPlugin from "./routes/tickets.js"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; dotenv.config(); const now = () => Date.now(); async function init() { + const dynamoClient = new DynamoDBClient({ + region: genericConfig.AwsRegion, + }); + + const secretsManagerClient = new SecretsManagerClient({ + region: genericConfig.AwsRegion, + }); + const app: FastifyInstance = fastify({ logger: { level: process.env.LOG_LEVEL || "info", @@ -70,6 +80,8 @@ async function init() { app.environmentConfig = environmentConfig[app.runEnvironment as RunEnvironment]; app.nodeCache = new NodeCache({ checkperiod: 30 }); + app.dynamoClient = dynamoClient; + app.secretsManagerClient = secretsManagerClient; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; @@ -108,7 +120,7 @@ async function init() { await app.register(cors, { origin: app.environmentConfig.ValidCorsOrigins, }); - + app.log.info("Initialized new Fastify instance..."); return app; } diff --git a/src/api/package.json b/src/api/package.json index 1141ef6..9f0467c 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -15,7 +15,33 @@ "prettier:write": "prettier --write *.ts **/*.ts" }, "dependencies": { + "@aws-sdk/client-dynamodb": "^3.624.0", + "@aws-sdk/client-secrets-manager": "^3.624.0", "@aws-sdk/client-sts": "^3.726.0", - "node-cache": "^5.1.2" + "@aws-sdk/util-dynamodb": "^3.624.0", + "@azure/msal-node": "^2.16.1", + "@fastify/auth": "^5.0.1", + "@fastify/aws-lambda": "^5.0.0", + "@fastify/caching": "^9.0.1", + "@fastify/cors": "^10.0.1", + "@touch4it/ical-timezones": "^1.9.0", + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "esbuild": "^0.24.2", + "fastify": "^5.1.0", + "fastify-plugin": "^4.5.1", + "ical-generator": "^7.2.0", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", + "node-cache": "^5.1.2", + "pluralize": "^8.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.2", + "zod-validation-error": "^3.3.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0" } -} +} \ No newline at end of file diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index 2361f7a..1e5fa54 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -53,15 +53,9 @@ export type AadToken = { ver: string; roles?: string[]; }; -const smClient = new SecretsManagerClient({ - region: genericConfig.AwsRegion, -}); - -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); export const getSecretValue = async ( + smClient: SecretsManagerClient, secretId: string, ): Promise | null | SecretConfig> => { const data = await smClient.send( @@ -118,7 +112,10 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { signingKey = process.env.JwtSigningKey || (( - (await getSecretValue(genericConfig.ConfigSecretName)) || { + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || { jwt_key: "", } ).jwt_key as string) || @@ -168,7 +165,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { if (verifiedTokenData.groups) { const groupRoles = await Promise.allSettled( verifiedTokenData.groups.map((x) => - getGroupRoles(dynamoClient, fastify, x), + getGroupRoles(fastify.dynamoClient, fastify, x), ), ); for (const result of groupRoles) { @@ -201,7 +198,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { if (request.username) { try { const userAuth = await getUserRoles( - dynamoClient, + fastify.dynamoClient, fastify, request.username, ); diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index d50406e..2d53cad 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -5,7 +5,6 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { OrganizationList } from "../../common/orgs.js"; import { DeleteItemCommand, - DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, @@ -27,6 +26,7 @@ import { IUpdateDiscord, updateDiscord } from "../functions/discord.js"; // POST const repeatOptions = ["weekly", "biweekly"] as const; +const EVENT_CACHE_SECONDS = 90; export type EventRepeatOptions = (typeof repeatOptions)[number]; const baseSchema = z.object({ @@ -80,10 +80,6 @@ const getEventsSchema = z.array(getEventSchema); export type EventsGetResponse = z.infer; type EventsGetQueryParams = { upcomingOnly?: boolean }; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.post<{ Body: EventPostRequest }>( "/:id?", @@ -106,7 +102,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { ).id; const entryUUID = userProvidedId || randomUUID(); if (userProvidedId) { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new GetItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: { id: { S: userProvidedId } }, @@ -128,7 +124,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { : new Date().toISOString(), updatedAt: new Date().toISOString(), }; - await dynamoClient.send( + await fastify.dynamoClient.send( new PutItemCommand({ TableName: genericConfig.EventsDynamoTableName, Item: marshall(entry), @@ -140,18 +136,23 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } try { if (request.body.featured && !request.body.repeats) { - await updateDiscord(entry, false, request.log); + await updateDiscord( + fastify.secretsManagerClient, + entry, + false, + request.log, + ); } } catch (e: unknown) { // restore original DB status if Discord fails. - await dynamoClient.send( + await fastify.dynamoClient.send( new DeleteItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: { id: { S: entryUUID } }, }), ); if (userProvidedId) { - await dynamoClient.send( + await fastify.dynamoClient.send( new PutItemCommand({ TableName: genericConfig.EventsDynamoTableName, Item: originalEvent, @@ -198,7 +199,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request: FastifyRequest, reply) => { const id = request.params.id; try { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new QueryCommand({ TableName: genericConfig.EventsDynamoTableName, KeyConditionExpression: "#id = :id", @@ -241,13 +242,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request: FastifyRequest, reply) => { const id = request.params.id; try { - await dynamoClient.send( + await fastify.dynamoClient.send( new DeleteItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: marshall({ id }), }), ); - await updateDiscord({ id } as IUpdateDiscord, true, request.log); + await updateDiscord( + fastify.secretsManagerClient, + { id } as IUpdateDiscord, + true, + request.log, + ); reply.send({ id, resource: `/api/v1/events/${id}`, @@ -285,8 +291,20 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { }, async (request: FastifyRequest, reply) => { const upcomingOnly = request.query?.upcomingOnly || false; + const cachedResponse = fastify.nodeCache.get( + `events-upcoming_only=${upcomingOnly}`, + ); + if (cachedResponse) { + reply + .header( + "cache-control", + "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", + ) + .header("acm-cache-status", "hit") + .send(cachedResponse); + } try { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new ScanCommand({ TableName: genericConfig.EventsDynamoTableName }), ); const items = response.Items?.map((item) => unmarshall(item)); @@ -322,11 +340,17 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } }); } + fastify.nodeCache.set( + `events-upcoming_only=${upcomingOnly}`, + parsedItems, + EVENT_CACHE_SECONDS, + ); reply .header( "cache-control", "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", ) + .header("acm-cache-status", "miss") .send(parsedItems); } catch (e: unknown) { if (e instanceof Error) { diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 9a99caf..8c6a77e 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -39,10 +39,6 @@ import { getGroupRoles, } from "../functions/authorization.js"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.get<{ Body: undefined; @@ -67,7 +63,11 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { try { const groupId = (request.params as Record).groupId; - const roles = await getGroupRoles(dynamoClient, fastify, groupId); + const roles = await getGroupRoles( + fastify.dynamoClient, + fastify, + groupId, + ); return reply.send(roles); } catch (e: unknown) { if (e instanceof BaseError) { @@ -120,7 +120,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { createdAt: timestamp, }), }); - await dynamoClient.send(command); + await fastify.dynamoClient.send(command); fastify.nodeCache.set( `grouproles-${groupId}`, request.body.roles, @@ -160,6 +160,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const emails = request.body.emails; const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); if (!entraIdToken) { @@ -246,6 +247,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); const addResults = await Promise.allSettled( @@ -369,6 +371,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); const response = await listGroupMembers(entraIdToken, groupId); diff --git a/src/api/routes/ics.ts b/src/api/routes/ics.ts index 706dc10..cbe03a7 100644 --- a/src/api/routes/ics.ts +++ b/src/api/routes/ics.ts @@ -18,10 +18,6 @@ import { getVtimezoneComponent } from "@touch4it/ical-timezones"; import { OrganizationList } from "../../common/orgs.js"; import { EventRepeatOptions } from "./events.js"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const repeatingIcalMap: Record = { weekly: { freq: ICalEventRepeatingFreq.WEEKLY }, @@ -54,7 +50,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { queryParams = { ...queryParams, }; - response = await dynamoClient.send( + response = await fastify.dynamoClient.send( new QueryCommand({ ...queryParams, ExpressionAttributeValues: { @@ -67,7 +63,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { }), ); } else { - response = await dynamoClient.send(new ScanCommand(queryParams)); + response = await fastify.dynamoClient.send(new ScanCommand(queryParams)); } const dynamoItems = response.Items ? response.Items.map((x) => unmarshall(x)) diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index ddf2984..3970780 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -93,10 +93,6 @@ const postSchema = z.union([postMerchSchema, postTicketSchema]); type VerifyPostRequest = z.infer; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - type TicketsGetRequest = { Params: { id: string }; Querystring: { type: string }; @@ -140,7 +136,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); const merchItems: ItemMetadata[] = []; - const response = await dynamoClient.send(merchCommand); + const response = await fastify.dynamoClient.send(merchCommand); const now = new Date(); if (response.Items) { @@ -175,7 +171,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); const ticketItems: TicketItemMetadata[] = []; - const ticketResponse = await dynamoClient.send(ticketCommand); + const ticketResponse = await fastify.dynamoClient.send(ticketCommand); if (ticketResponse.Items) { for (const item of ticketResponse.Items.map((x) => unmarshall(x))) { @@ -243,7 +239,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { ":itemId": { S: eventId }, }, }); - const response = await dynamoClient.send(command); + const response = await fastify.dynamoClient.send(command); if (!response.Items) { throw new NotFoundError({ endpointName: `/api/v1/tickets/${eventId}`, @@ -340,7 +336,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { } let purchaserData: PurchaseData; try { - const ticketEntry = await dynamoClient.send(command); + const ticketEntry = await fastify.dynamoClient.send(command); if (!ticketEntry.Attributes) { throw new DatabaseFetchError({ message: "Could not find ticket data", @@ -436,7 +432,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `Unknown verification type!`, }); } - await dynamoClient.send(command); + await fastify.dynamoClient.send(command); reply.send(response); request.log.info( { type: "audit", actor: request.username, target: ticketId }, diff --git a/src/api/tsconfig.json b/src/api/tsconfig.json index ae443bb..7a90b5c 100644 --- a/src/api/tsconfig.json +++ b/src/api/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node20/tsconfig.json", + "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { "module": "Node16", "rootDir": "../", diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 7377098..7f0e498 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -3,6 +3,8 @@ import { AppRoles, RunEnvironment } from "../common/roles.js"; import { AadToken } from "./plugins/auth.js"; import { ConfigType } from "../common/config.js"; import NodeCache from "node-cache"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; declare module "fastify" { interface FastifyInstance { authenticate: ( @@ -22,6 +24,8 @@ declare module "fastify" { runEnvironment: RunEnvironment; environmentConfig: ConfigType; nodeCache: NodeCache; + dynamoClient: DynamoDBClient; + secretsManagerClient: SecretsManagerClient; } interface FastifyRequest { startTime: number; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 5008a6a..f4f50c3 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -13,6 +13,7 @@ import { } from "./secret.testdata.js"; import jwt from "jsonwebtoken"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; +import { beforeEach, describe } from "node:test"; const ddbMock = mockClient(SecretsManagerClient); @@ -50,40 +51,46 @@ vi.stubEnv("JwtSigningKey", jwt_secret); const testJwt = createJwt(); const testJwtNoGroups = createJwtNoGroups(); -test("Test happy path", async () => { - ddbMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, +describe("Test authentication", () => { + test("Test happy path", async () => { + ddbMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/protected", + headers: { + authorization: `Bearer ${testJwt}`, + }, + }); + expect(response.statusCode).toBe(200); + const jsonBody = await response.json(); + expect(jsonBody).toEqual({ + username: "infra-unit-test@acm.illinois.edu", + roles: allAppRoles, + }); }); - const response = await app.inject({ - method: "GET", - url: "/api/v1/protected", - headers: { - authorization: `Bearer ${testJwt}`, - }, - }); - expect(response.statusCode).toBe(200); - const jsonBody = await response.json(); - expect(jsonBody).toEqual({ - username: "infra-unit-test@acm.illinois.edu", - roles: allAppRoles, - }); -}); -test("Test user-specific role grants", async () => { - ddbMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, + test("Test user-specific role grants", async () => { + ddbMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/protected", + headers: { + authorization: `Bearer ${testJwtNoGroups}`, + }, + }); + expect(response.statusCode).toBe(200); + const jsonBody = await response.json(); + expect(jsonBody).toEqual({ + username: "infra-unit-test-nogrp@acm.illinois.edu", + roles: [AppRoles.TICKETS_SCANNER], + }); }); - const response = await app.inject({ - method: "GET", - url: "/api/v1/protected", - headers: { - authorization: `Bearer ${testJwtNoGroups}`, - }, - }); - expect(response.statusCode).toBe(200); - const jsonBody = await response.json(); - expect(jsonBody).toEqual({ - username: "infra-unit-test-nogrp@acm.illinois.edu", - roles: [AppRoles.TICKETS_SCANNER], + + beforeEach(() => { + (app as any).nodeCache.flushAll(); }); }); diff --git a/tests/unit/discordEvent.test.ts b/tests/unit/discordEvent.test.ts index 3d13829..11461e4 100644 --- a/tests/unit/discordEvent.test.ts +++ b/tests/unit/discordEvent.test.ts @@ -85,6 +85,7 @@ describe("Test Events <-> Discord integration", () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); smMock.reset(); vi.clearAllMocks(); diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index f1d4961..0f3d37e 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -42,6 +42,7 @@ const app = await init(); describe("Test Modify Group and List Group Routes", () => { beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); smMock.on(GetSecretValueCommand).resolves({ SecretString: JSON.stringify({ jwt_key: "test_jwt_key" }), @@ -130,6 +131,7 @@ describe("Test Modify Group and List Group Routes", () => { await app.close(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); vi.useFakeTimers(); (getEntraIdToken as any).mockImplementation(async () => { diff --git a/tests/unit/entraInviteUser.test.ts b/tests/unit/entraInviteUser.test.ts index 40e7e2c..59d7e24 100644 --- a/tests/unit/entraInviteUser.test.ts +++ b/tests/unit/entraInviteUser.test.ts @@ -95,6 +95,7 @@ describe("Test Microsoft Entra ID user invitation", () => { }); beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); vi.useFakeTimers(); // Re-implement the mock diff --git a/tests/unit/eventPost.test.ts b/tests/unit/eventPost.test.ts index f846b4a..437f127 100644 --- a/tests/unit/eventPost.test.ts +++ b/tests/unit/eventPost.test.ts @@ -197,6 +197,7 @@ afterAll(async () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); smMock.reset(); vi.useFakeTimers(); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 4b16227..6c000d9 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -29,7 +29,6 @@ test("Test getting events", async () => { }); test("Test dynamodb error handling", async () => { - ddbMock.on(ScanCommand).rejects("Could not get data."); const response = await app.inject({ method: "GET", url: "/api/v1/events", @@ -64,6 +63,7 @@ afterAll(async () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); vi.useFakeTimers(); }); diff --git a/tests/unit/health.test.ts b/tests/unit/health.test.ts index e72e85a..9765850 100644 --- a/tests/unit/health.test.ts +++ b/tests/unit/health.test.ts @@ -3,7 +3,7 @@ import init from "../../src/api/index.js"; import { EventGetResponse } from "../../src/api/routes/events.js"; const app = await init(); -test("Test getting events", async () => { +test("Test getting health", async () => { const response = await app.inject({ method: "GET", url: "/api/v1/healthz", diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 50e9805..7a605f7 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -1,4 +1,4 @@ -import { afterAll, expect, test } from "vitest"; +import { afterAll, expect, test, beforeEach } from "vitest"; import init from "../../src/api/index.js"; const app = await init(); @@ -13,3 +13,6 @@ test("Test getting the list of organizations succeeds", async () => { afterAll(async () => { await app.close(); }); +beforeEach(() => { + (app as any).nodeCache.flushAll(); +}); diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index a056519..662c8e0 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -375,4 +375,5 @@ afterAll(async () => { beforeEach(() => { ddbMock.reset(); vi.useFakeTimers(); + (app as any).nodeCache.flushAll(); }); diff --git a/yarn.lock b/yarn.lock index f956643..d7df54e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3212,10 +3212,10 @@ resolved "https://registry.yarnpkg.com/@touch4it/ical-timezones/-/ical-timezones-1.9.0.tgz#bbd85014f55b5cc3e9079ed7caccd8649b5170a3" integrity sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g== -"@tsconfig/node20@^20.1.4": - version "20.1.4" - resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928" - integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg== +"@tsconfig/node22@^22.0.0": + version "22.0.0" + resolved "https://registry.yarnpkg.com/@tsconfig/node22/-/node22-22.0.0.tgz#0bdaf702f2b7594383d24d7b2b8d557dcfdca1ed" + integrity sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg== "@types/aria-query@^5.0.1": version "5.0.4" @@ -4044,16 +4044,7 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== -axios@1.6.7: - version "1.6.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" - integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== - dependencies: - follow-redirects "^1.15.4" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -axios@^1.7.3: +axios@^1.7.3, axios@^1.7.7: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -5012,7 +5003,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.24.2: version "0.24.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== @@ -5745,7 +5736,7 @@ flatted@^3.2.9, flatted@^3.3.1: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== -follow-redirects@^1.15.4, follow-redirects@^1.15.6: +follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== @@ -7090,7 +7081,7 @@ mnemonist@0.39.8: dependencies: obliterator "^2.0.1" -moment-timezone@^0.5.44, moment-timezone@^0.5.45: +moment-timezone@^0.5.45: version "0.5.46" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a" integrity sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw== @@ -7167,15 +7158,15 @@ node-cache@^5.1.2: dependencies: clone "2.x" -node-ical@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.18.0.tgz#919ab65f43cdfebb4ac9a1c2acca2b5e62cc003f" - integrity sha512-FrOUPztjw9OUgSB9o/ffhl86BiVClQTut97C2NqCwKIgOAcKPEw5UQMuSuNJO/Y4hqTyJdKZh2TCqNHQnE9YFg== +node-ical@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.20.1.tgz#3a67319af9be956b3cc81cdf6716d1352eaefaca" + integrity sha512-NrXgzDJd6XcyX9kDMJVA3xYCZmntY7ghA2BOdBeYr3iu8tydHOAb+68jPQhF9V2CRQ0/386X05XhmLzQUN0+Hw== dependencies: - axios "1.6.7" - moment-timezone "^0.5.44" + axios "^1.7.7" + moment-timezone "^0.5.45" rrule "2.8.1" - uuid "^9.0.0" + uuid "^10.0.0" node-releases@^2.0.19: version "2.0.19" @@ -9318,6 +9309,11 @@ uuid-random@^1.3.2: resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0" integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"