diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index cdc88052..11b8e05a 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -99,14 +99,6 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache - - Sid: DynamoDBRateLimitTableAccess - Effect: Allow - Action: - - dynamodb:DescribeTable - - dynamodb:UpdateItem - Resource: - - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter - - Sid: DynamoDBAuditLogTableAccess Effect: Allow Action: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 69a15a79..88326e17 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -408,30 +408,6 @@ Resources: - AttributeName: userEmail KeyType: HASH - RateLimiterTable: - Type: "AWS::DynamoDB::Table" - DeletionPolicy: "Delete" - UpdateReplacePolicy: "Delete" - Properties: - BillingMode: "PAY_PER_REQUEST" - TableName: infra-core-api-rate-limiter - DeletionProtectionEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: false - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" diff --git a/package.json b/package.json index 784b7986..6caff875 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@eslint/js": "^9.25.1", "@playwright/test": "^1.52.0", "@tsconfig/node22": "^22.0.1", + "@types/ioredis-mock": "^8.2.5", "@types/node": "^22.15.2", "@types/pluralize": "^0.0.33", "@types/react": "^19.1.2", @@ -63,6 +64,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", + "ioredis-mock": "^8.9.0", "jsdom": "^26.1.0", "node-ical": "^0.20.1", "postcss": "^8.5.3", @@ -85,4 +87,4 @@ "resolutions": { "pdfjs-dist": "^4.8.69" } -} \ No newline at end of file +} diff --git a/src/api/build.js b/src/api/build.js index 9946c61a..38133509 100644 --- a/src/api/build.js +++ b/src/api/build.js @@ -25,6 +25,7 @@ const commonParams = { "@fastify/swagger", "@fastify/swagger-ui", "argon2", + "ioredis", ], alias: { "moment-timezone": resolve( diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts index d124651e..06b5e5c6 100644 --- a/src/api/functions/authorization.ts +++ b/src/api/functions/authorization.ts @@ -9,14 +9,8 @@ export const AUTH_DECISION_CACHE_SECONDS = 180; export async function getUserRoles( dynamoClient: DynamoDBClient, - fastifyApp: FastifyInstance, userId: string, ): Promise { - const cachedValue = fastifyApp.nodeCache.get(`userroles-${userId}`); - if (cachedValue) { - fastifyApp.log.info(`Returning cached auth decision for user ${userId}`); - return cachedValue as AppRoles[]; - } const tableName = `${genericConfig.IAMTablePrefix}-userroles`; const command = new GetItemCommand({ TableName: tableName, @@ -38,31 +32,15 @@ export async function getUserRoles( return []; } if (items.roles[0] === "all") { - fastifyApp.nodeCache.set( - `userroles-${userId}`, - allAppRoles, - AUTH_DECISION_CACHE_SECONDS, - ); return allAppRoles; } - fastifyApp.nodeCache.set( - `userroles-${userId}`, - items.roles, - AUTH_DECISION_CACHE_SECONDS, - ); return items.roles as AppRoles[]; } export async function getGroupRoles( dynamoClient: DynamoDBClient, - fastifyApp: FastifyInstance, groupId: string, ) { - const cachedValue = fastifyApp.nodeCache.get(`grouproles-${groupId}`); - if (cachedValue) { - fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`); - return cachedValue as AppRoles[]; - } const tableName = `${genericConfig.IAMTablePrefix}-grouproles`; const command = new GetItemCommand({ TableName: tableName, @@ -77,34 +55,14 @@ export async function getGroupRoles( }); } if (!response.Item) { - fastifyApp.nodeCache.set( - `grouproles-${groupId}`, - [], - AUTH_DECISION_CACHE_SECONDS, - ); return []; } const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] }; if (!("roles" in items)) { - fastifyApp.nodeCache.set( - `grouproles-${groupId}`, - [], - AUTH_DECISION_CACHE_SECONDS, - ); return []; } if (items.roles[0] === "all") { - fastifyApp.nodeCache.set( - `grouproles-${groupId}`, - allAppRoles, - AUTH_DECISION_CACHE_SECONDS, - ); return allAppRoles; } - fastifyApp.nodeCache.set( - `grouproles-${groupId}`, - items.roles, - AUTH_DECISION_CACHE_SECONDS, - ); return items.roles as AppRoles[]; } diff --git a/src/api/functions/discord.ts b/src/api/functions/discord.ts index 9e06911d..f77031cd 100644 --- a/src/api/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -14,7 +14,7 @@ import moment from "moment-timezone"; import { FastifyBaseLogger } from "fastify"; import { DiscordEventError } from "../../common/errors/index.js"; import { getSecretValue } from "../plugins/auth.js"; -import { genericConfig } from "../../common/config.js"; +import { genericConfig, SecretConfig } from "../../common/config.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; // https://stackoverflow.com/a/3809435/5684541 @@ -26,19 +26,16 @@ export type IUpdateDiscord = EventPostRequest & { id: string }; const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/; export const updateDiscord = async ( - smClient: SecretsManagerClient, + secretApiConfig: SecretConfig, event: IUpdateDiscord, actor: string, isDelete: boolean = false, logger: FastifyBaseLogger, ): Promise => { - const secretApiConfig = - (await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {}; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let payload: GuildScheduledEventCreateOptions | null = null; - client.once(Events.ClientReady, async (readyClient: Client) => { - logger.info(`Logged in as ${readyClient.user.tag}`); + logger.debug(`Logged in as ${readyClient.user.tag}`); const guildID = secretApiConfig.discord_guild_id; const guild = await client.guilds.fetch(guildID?.toString() || ""); const discordEvents = await guild.scheduledEvents.fetch(); @@ -69,6 +66,7 @@ export const updateDiscord = async ( logger.warn(`Event with id ${id} not found in Discord`); } await client.destroy(); + logger.debug("Logged out of Discord."); return null; } @@ -108,6 +106,7 @@ export const updateDiscord = async ( } await client.destroy(); + logger.debug("Logged out of Discord."); return payload; }); diff --git a/src/api/functions/rateLimit.ts b/src/api/functions/rateLimit.ts index 27abfd01..96467e1d 100644 --- a/src/api/functions/rateLimit.ts +++ b/src/api/functions/rateLimit.ts @@ -1,66 +1,60 @@ -import { - ConditionalCheckFailedException, - UpdateItemCommand, - DynamoDBClient, -} from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "common/config.js"; +import { Redis } from "ioredis"; // Make sure you have ioredis installed (npm install ioredis) interface RateLimitParams { - ddbClient: DynamoDBClient; + redisClient: Redis; rateLimitIdentifier: string; duration: number; limit: number; userIdentifier: string; } +interface RateLimitResult { + limited: boolean; + resetTime: number; + used: number; +} + +const LUA_SCRIPT_INCREMENT_AND_EXPIRE = ` + local count = redis.call("INCR", KEYS[1]) + -- If the count is 1, this means the key was just created by INCR (first request in this window). + -- So, we set its expiration time to the end of the current window. + if tonumber(count) == 1 then + redis.call("EXPIREAT", KEYS[1], ARGV[1]) + end + return count +`; + export async function isAtLimit({ - ddbClient, + redisClient, rateLimitIdentifier, duration, limit, userIdentifier, -}: RateLimitParams): Promise<{ - limited: boolean; - resetTime: number; - used: number; -}> { +}: RateLimitParams): Promise { + if (duration <= 0) { + throw new Error("Rate limit duration must be a positive number."); + } + if (limit < 0) { + throw new Error("Rate limit must be a non-negative number."); + } + const nowInSeconds = Math.floor(Date.now() / 1000); - const timeWindow = Math.floor(nowInSeconds / duration) * duration; - const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`; + const timeWindowStart = Math.floor(nowInSeconds / duration) * duration; + const key = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindowStart}`; + const expiryTimestamp = timeWindowStart + duration; - try { - const result = await ddbClient.send( - new UpdateItemCommand({ - TableName: genericConfig.RateLimiterDynamoTableName, - Key: { - PK: { S: PK }, - SK: { S: "counter" }, - }, - UpdateExpression: "ADD #rateLimitCount :inc SET #ttl = :ttl", - ConditionExpression: - "attribute_not_exists(#rateLimitCount) OR #rateLimitCount <= :limit", - ExpressionAttributeValues: { - ":inc": { N: "1" }, - ":limit": { N: limit.toString() }, - ":ttl": { N: (timeWindow + duration).toString() }, - }, - ExpressionAttributeNames: { - "#rateLimitCount": "rateLimitCount", - "#ttl": "ttl", - }, - ReturnValues: "UPDATED_NEW", - ReturnValuesOnConditionCheckFailure: "ALL_OLD", - }), - ); - return { - limited: false, - used: parseInt(result.Attributes?.rateLimitCount.N || "1", 10), - resetTime: timeWindow + duration, - }; - } catch (error) { - if (error instanceof ConditionalCheckFailedException) { - return { limited: true, resetTime: timeWindow + duration, used: limit }; - } - throw error; - } + const currentUsedCount = (await redisClient.eval( + LUA_SCRIPT_INCREMENT_AND_EXPIRE, + 1, // Number of keys + key, // KEYS[1] + expiryTimestamp.toString(), // ARGV[1] + )) as number; // The script returns the count, which is a number. + const isLimited = currentUsedCount > limit; + const resetTime = expiryTimestamp; + + return { + limited: isLimited, + resetTime, + used: isLimited ? limit : currentUsedCount, + }; } diff --git a/src/api/index.ts b/src/api/index.ts index abe2bff7..5cd222b5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,14 +4,18 @@ import { randomUUID } from "crypto"; import fastify, { FastifyInstance } from "fastify"; import FastifyAuthProvider from "@fastify/auth"; import fastifyStatic from "@fastify/static"; -import fastifyAuthPlugin from "./plugins/auth.js"; +import fastifyAuthPlugin, { getSecretValue } from "./plugins/auth.js"; import protectedRoute from "./routes/protected.js"; import errorHandlerPlugin from "./plugins/errorHandler.js"; import { RunEnvironment, runEnvironments } from "../common/roles.js"; import { InternalServerError } from "../common/errors/index.js"; import eventsPlugin from "./routes/events.js"; import cors from "@fastify/cors"; -import { environmentConfig, genericConfig } from "../common/config.js"; +import { + environmentConfig, + genericConfig, + SecretConfig, +} from "../common/config.js"; import organizationsPlugin from "./routes/organizations.js"; import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js"; import evaluatePoliciesPlugin from "./plugins/evaluatePolicies.js"; @@ -43,6 +47,7 @@ import { import { ZodOpenApiVersion } from "zod-openapi"; import { withTags } from "./components/index.js"; import apiKeyRoute from "./routes/apiKey.js"; +import RedisModule from "ioredis"; dotenv.config(); @@ -56,6 +61,12 @@ async function init(prettyPrint: boolean = false) { const secretsManagerClient = new SecretsManagerClient({ region: genericConfig.AwsRegion, }); + const secret = (await getSecretValue( + secretsManagerClient, + genericConfig.ConfigSecretName, + )) as SecretConfig; + const redisClient = new RedisModule.default(secret.redis_url); + const transport = prettyPrint ? { target: "pino-pretty", @@ -224,6 +235,14 @@ async function init(prettyPrint: boolean = false) { app.nodeCache = new NodeCache({ checkperiod: 30 }); app.dynamoClient = dynamoClient; app.secretsManagerClient = secretsManagerClient; + app.redisClient = redisClient; + app.secretConfig = secret; + app.refreshSecretConfig = async () => { + app.secretConfig = (await getSecretValue( + app.secretsManagerClient, + genericConfig.ConfigSecretName, + )) as SecretConfig; + }; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; @@ -250,7 +269,13 @@ async function init(prettyPrint: boolean = false) { summary: "Verify that the API server is healthy.", }), }, - (_, reply) => reply.send({ message: "UP" }), + async (_, reply) => { + const startTime = new Date().getTime(); + await app.redisClient.ping(); + const redisTime = new Date().getTime(); + app.log.debug(`Redis latency: ${redisTime - startTime} ms.`); + return reply.send({ message: "UP" }); + }, ); await app.register( async (api, _options) => { @@ -295,7 +320,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { process.exit(1); } const app = await init(true); - app.listen({ port: 8080 }, async (err) => { + app.listen({ port: 8080 }, (err) => { /* eslint no-console: ["error", {"allow": ["log", "error"]}] */ if (err) { console.error(err); diff --git a/src/api/lambda.ts b/src/api/lambda.ts index e3111316..73598614 100644 --- a/src/api/lambda.ts +++ b/src/api/lambda.ts @@ -6,6 +6,7 @@ const app = await init(); const handler = awsLambdaFastify(app, { decorateRequest: false, serializeLambdaArguments: true, + callbackWaitsForEmptyEventLoop: false, }); await app.ready(); // needs to be placed after awsLambdaFastify call because of the decoration: https://github.com/fastify/aws-lambda-fastify/blob/master/index.js#L9 export { handler }; diff --git a/src/api/package.json b/src/api/package.json index 4ca4b0d5..3bd8a9f0 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -45,6 +45,7 @@ "fastify-raw-body": "^5.0.0", "fastify-zod-openapi": "^4.1.1", "ical-generator": "^8.1.1", + "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", "moment": "^2.30.1", diff --git a/src/api/package.lambda.json b/src/api/package.lambda.json index 69de8087..768db6af 100644 --- a/src/api/package.lambda.json +++ b/src/api/package.lambda.json @@ -14,7 +14,8 @@ "@fastify/swagger-ui": "^5.2.2", "zod": "^3.23.8", "zod-openapi": "^4.2.4", - "argon2": "^0.41.1" + "argon2": "^0.41.1", + "ioredis": "^5.6.1" }, "devDependencies": {} } diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index e09ce492..c343a651 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -1,7 +1,7 @@ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import jwksClient from "jwks-rsa"; -import jwt, { Algorithm } from "jsonwebtoken"; +import jwt, { Algorithm, Jwt } from "jsonwebtoken"; import { SecretsManagerClient, GetSecretValueCommand, @@ -13,8 +13,12 @@ import { UnauthenticatedError, UnauthorizedError, } from "../../common/errors/index.js"; -import { genericConfig, SecretConfig } from "../../common/config.js"; -import { getGroupRoles, getUserRoles } from "../functions/authorization.js"; +import { SecretConfig } from "../../common/config.js"; +import { + AUTH_DECISION_CACHE_SECONDS, + getGroupRoles, + getUserRoles, +} from "../functions/authorization.js"; import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js"; export function intersection(setA: Set, setB: Set): Set { @@ -26,7 +30,7 @@ export function intersection(setA: Set, setB: Set): Set { } return _intersection; } - +const JWKS_CACHE_SECONDS = 21600; // 6 hours; export type AadToken = { aud: string; iss: string; @@ -75,6 +79,31 @@ export const getSecretValue = async ( } }; +export const getUserIdentifier = (request: FastifyRequest): string | null => { + try { + const apiKeyHeader = request.headers ? request.headers["x-api-key"] : null; + if (apiKeyHeader) { + const apiKeyValue = + typeof apiKeyHeader === "string" ? apiKeyHeader : apiKeyHeader[0]; + const { id } = getApiKeyParts(apiKeyValue); + return id; + } + const authHeader = request.headers ? request.headers.authorization : null; + if (!authHeader) { + return request.ip; + } + const [method, token] = authHeader.split(" "); + const decoded = jwt.decode(token); + if (!decoded || typeof decoded === "string") { + throw new InternalServerError({ message: "Could not decode JWT." }); + } + return (decoded as AadToken).sub || null; + } catch (e) { + request.log.error("Failed to determine user identifier", e); + return null; + } +}; + const authPlugin: FastifyPluginAsync = async (fastify, _options) => { const handleApiKeyAuthentication = async ( request: FastifyRequest, @@ -126,7 +155,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { validRoles: AppRoles[], disableApiKeyAuth: boolean, ): Promise> => { - const userRoles = new Set([] as AppRoles[]); + const startTime = new Date().getTime(); try { if (!disableApiKeyAuth) { const apiKeyHeader = request.headers @@ -166,14 +195,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { } signingKey = process.env.JwtSigningKey || - (( - (await getSecretValue( - fastify.secretsManagerClient, - genericConfig.ConfigSecretName, - )) || { - jwt_key: "", - } - ).jwt_key as string) || + (fastify.secretConfig.jwt_key as string) || ""; if (signingKey === "") { throw new UnauthenticatedError({ @@ -203,10 +225,28 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { header: decoded?.header, audience: `api://${AadClientId}`, }; - const client = jwksClient({ - jwksUri: "https://login.microsoftonline.com/common/discovery/keys", - }); - signingKey = (await client.getSigningKey(header.kid)).getPublicKey(); + const cachedJwksSigningKey = await fastify.redisClient.get( + `jwksKey:${header.kid}`, + ); + if (cachedJwksSigningKey) { + signingKey = cachedJwksSigningKey; + request.log.debug("Got JWKS signing key from cache."); + } else { + const client = jwksClient({ + jwksUri: + "https://login.microsoftonline.com/common/discovery/keys", + }); + signingKey = ( + await client.getSigningKey(header.kid) + ).getPublicKey(); + await fastify.redisClient.set( + `jwksKey:${header.kid}`, + signingKey, + "EX", + JWKS_CACHE_SECONDS, + ); + request.log.debug("Got JWKS signing key from server."); + } } const verifiedTokenData = jwt.verify( @@ -214,62 +254,80 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { signingKey, verifyOptions, ) as AadToken; + request.log.debug( + `Start to verifying JWT took ${new Date().getTime() - startTime} ms.`, + ); request.tokenPayload = verifiedTokenData; request.username = verifiedTokenData.email || verifiedTokenData.upn?.replace("acm.illinois.edu", "illinois.edu") || verifiedTokenData.sub; const expectedRoles = new Set(validRoles); - if (verifiedTokenData.groups) { - const groupRoles = await Promise.allSettled( - verifiedTokenData.groups.map((x) => - getGroupRoles(fastify.dynamoClient, fastify, x), - ), - ); - for (const result of groupRoles) { - if (result.status === "fulfilled") { - for (const role of result.value) { - userRoles.add(role); + const cachedRoles = await fastify.redisClient.get( + `authCache:${request.username}:roles`, + ); + if (cachedRoles) { + request.userRoles = new Set(JSON.parse(cachedRoles)); + request.log.debug("Retrieved user roles from cache."); + } else { + const userRoles = new Set([] as AppRoles[]); + if (verifiedTokenData.groups) { + const groupRoles = await Promise.allSettled( + verifiedTokenData.groups.map((x) => + getGroupRoles(fastify.dynamoClient, x), + ), + ); + for (const result of groupRoles) { + if (result.status === "fulfilled") { + for (const role of result.value) { + userRoles.add(role); + } + } else { + request.log.warn(`Failed to get group roles: ${result.reason}`); } - } else { - request.log.warn(`Failed to get group roles: ${result.reason}`); } - } - } else if ( - verifiedTokenData.roles && - fastify.environmentConfig.AzureRoleMapping - ) { - for (const group of verifiedTokenData.roles) { - if (fastify.environmentConfig.AzureRoleMapping[group]) { - for (const role of fastify.environmentConfig.AzureRoleMapping[ - group - ]) { - userRoles.add(role); + } else if ( + verifiedTokenData.roles && + fastify.environmentConfig.AzureRoleMapping + ) { + for (const group of verifiedTokenData.roles) { + if (fastify.environmentConfig.AzureRoleMapping[group]) { + for (const role of fastify.environmentConfig.AzureRoleMapping[ + group + ]) { + userRoles.add(role); + } } } } - } - - // add user-specific role overrides - if (request.username) { - try { - const userAuth = await getUserRoles( - fastify.dynamoClient, - fastify, - request.username, - ); - for (const role of userAuth) { - userRoles.add(role); + // add user-specific role overrides + if (request.username) { + try { + const userAuth = await getUserRoles( + fastify.dynamoClient, + request.username, + ); + for (const role of userAuth) { + userRoles.add(role); + } + } catch (e) { + request.log.warn( + `Failed to get user role mapping for ${request.username}: ${e}`, + ); } - } catch (e) { - request.log.warn( - `Failed to get user role mapping for ${request.username}: ${e}`, - ); } + request.userRoles = userRoles; + fastify.redisClient.set( + `authCache:${request.username}:roles`, + JSON.stringify([...userRoles]), + "EX", + AUTH_DECISION_CACHE_SECONDS, + ); + request.log.debug("Retrieved user roles from database."); } if ( expectedRoles.size > 0 && - intersection(userRoles, expectedRoles).size === 0 + intersection(request.userRoles, expectedRoles).size === 0 ) { throw new UnauthorizedError({ message: "User does not have the privileges for this task.", @@ -285,7 +343,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { }); } if (err instanceof Error) { - request.log.error(`Failed to verify JWT: ${err.toString()} `); + request.log.error(`Failed to get user roles: ${err.toString()}`); throw err; } throw new UnauthenticatedError({ @@ -293,8 +351,10 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { }); } request.log.info(`authenticated request from ${request.username} `); - request.userRoles = userRoles; - return userRoles; + request.log.debug( + `Start to authorization decision took ${new Date().getTime() - startTime} ms.`, + ); + return request.userRoles; }, ); }; diff --git a/src/api/plugins/rateLimiter.ts b/src/api/plugins/rateLimiter.ts index 9599ce32..b663cdef 100644 --- a/src/api/plugins/rateLimiter.ts +++ b/src/api/plugins/rateLimiter.ts @@ -1,6 +1,8 @@ import fp from "fastify-plugin"; import { isAtLimit } from "api/functions/rateLimit.js"; import { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify"; +import { getUserIdentifier } from "./auth.js"; +import { ValidationError } from "common/errors/index.js"; interface RateLimiterOptions { limit?: number | ((request: FastifyRequest) => number); @@ -20,7 +22,13 @@ const rateLimiterPlugin: FastifyPluginAsync = async ( fastify.addHook( "preHandler", async (request: FastifyRequest, reply: FastifyReply) => { - const userIdentifier = request.ip; + const startTime = new Date().getTime(); + const userIdentifier = getUserIdentifier(request); + if (!userIdentifier) { + throw new ValidationError({ + message: "Could not find user identifier.", + }); + } let computedLimit = limit; let computedIdentifier = rateLimitIdentifier; if (typeof computedLimit === "function") { @@ -30,12 +38,15 @@ const rateLimiterPlugin: FastifyPluginAsync = async ( computedIdentifier = computedIdentifier(request); } const { limited, resetTime, used } = await isAtLimit({ - ddbClient: fastify.dynamoClient, + redisClient: fastify.redisClient, rateLimitIdentifier: computedIdentifier, duration, limit: computedLimit, userIdentifier, }); + request.log.debug( + `Computing rate limit took ${new Date().getTime() - startTime} ms.`, + ); reply.header("X-RateLimit-Limit", computedLimit.toString()); reply.header("X-RateLimit-Reset", resetTime?.toString() || "0"); reply.header( diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index e9b2b46e..2480fce9 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -358,7 +358,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( try { if (request.body.featured && !request.body.repeats) { await updateDiscord( - fastify.secretsManagerClient, + fastify.secretConfig, entry, request.username, false, @@ -496,7 +496,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( }), ); await updateDiscord( - fastify.secretsManagerClient, + fastify.secretConfig, { id } as IUpdateDiscord, request.username, true, diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index dd80f1e6..3ac90440 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -121,11 +121,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { try { const groupId = request.params.groupId; - const roles = await getGroupRoles( - fastify.dynamoClient, - fastify, - groupId, - ); + const roles = await getGroupRoles(fastify.dynamoClient, groupId); return reply.send(roles); } catch (e: unknown) { if (e instanceof BaseError) { diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index acb56490..b156505e 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -106,16 +106,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { if (!request.username) { throw new UnauthenticatedError({ message: "No username found" }); } - const secretApiConfig = - (await getSecretValue( - fastify.secretsManagerClient, - genericConfig.ConfigSecretName, - )) || {}; - if (!secretApiConfig) { - throw new InternalServerError({ - message: "Could not connect to Stripe.", - }); - } + const secretApiConfig = fastify.secretConfig; const payload: StripeLinkCreateParams = { ...request.body, createdBy: request.username, diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 34ab418c..96028b46 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -2,13 +2,15 @@ import { FastifyRequest, FastifyInstance, FastifyReply } from "fastify"; import { AppRoles, RunEnvironment } from "../common/roles.js"; import { AadToken } from "./plugins/auth.js"; -import { ConfigType } from "../common/config.js"; +import { ConfigType, SecretConfig } from "../common/config.js"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { SQSClient } from "@aws-sdk/client-sqs"; import { CloudFrontKeyValueStoreClient } from "@aws-sdk/client-cloudfront-keyvaluestore"; import { AvailableAuthorizationPolicy } from "common/policies/definition.js"; +import type RedisModule from "ioredis"; +type Redis = RedisModule.default; declare module "fastify" { interface FastifyInstance { @@ -31,8 +33,11 @@ declare module "fastify" { nodeCache: NodeCache; dynamoClient: DynamoDBClient; sqsClient?: SQSClient; + redisClient: Redis; secretsManagerClient: SecretsManagerClient; cloudfrontKvClient: CloudFrontKeyValueStoreClient; + secretConfig: SecretConfig; + refreshSecretConfig: CallableFunction; } interface FastifyRequest { startTime: number; diff --git a/src/common/config.ts b/src/common/config.ts index b1cbc975..14937cf5 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -26,7 +26,6 @@ export type ConfigType = { }; export type GenericConfigType = { - RateLimiterDynamoTableName: string; EventsDynamoTableName: string; CacheDynamoTableName: string; LinkryDynamoTableName: string; @@ -65,7 +64,6 @@ export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; const genericConfig: GenericConfigType = { - RateLimiterDynamoTableName: "infra-core-api-rate-limiter", EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", CacheDynamoTableName: "infra-core-api-cache", @@ -149,6 +147,7 @@ export type SecretConfig = { apple_signing_cert_base64: string; stripe_secret_key: string; stripe_endpoint_secret: string; + redis_url: string; }; const roleArns = { diff --git a/tests/unit/apiKey.test.ts b/tests/unit/apiKey.test.ts index 57ffe02c..3858ffbb 100644 --- a/tests/unit/apiKey.test.ts +++ b/tests/unit/apiKey.test.ts @@ -2,12 +2,8 @@ import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { secretJson, secretObject } from "./secret.testdata.js"; +import { secretObject } from "./secret.testdata.js"; import supertest from "supertest"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { ConditionalCheckFailedException, DynamoDBClient, @@ -32,7 +28,6 @@ vi.mock("../../src/api/functions/apiKey.js", () => { // Mock DynamoDB client const dynamoMock = mockClient(DynamoDBClient); -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -42,13 +37,8 @@ const app = await init(); describe("API Key Route Tests", () => { beforeEach(() => { dynamoMock.reset(); - smMock.reset(); vi.clearAllMocks(); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - dynamoMock.on(TransactWriteItemsCommand).resolves({}); dynamoMock.on(ScanCommand).resolves({ diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index ed4bfb9a..a981846c 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,12 +1,7 @@ import { expect, test, vi } from "vitest"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { - secretJson, secretObject, jwtPayload, jwtPayloadNoGroups, @@ -15,8 +10,6 @@ import jwt from "jsonwebtoken"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; import { beforeEach, describe } from "node:test"; -const ddbMock = mockClient(SecretsManagerClient); - const app = await init(); const jwt_secret = secretObject["jwt_key"]; export function createJwt(date?: Date, groups?: string[], email?: string) { @@ -54,9 +47,6 @@ const testJwtNoGroups = createJwtNoGroups(); 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", @@ -73,9 +63,6 @@ describe("Test authentication", () => { }); test("Test user-specific role grants", async () => { - ddbMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const response = await app.inject({ method: "GET", url: "/api/v1/protected", @@ -93,5 +80,6 @@ describe("Test authentication", () => { beforeEach(() => { (app as any).nodeCache.flushAll(); + (app as any).redisClient.flushAll(); }); }); diff --git a/tests/unit/discordEvent.test.ts b/tests/unit/discordEvent.test.ts index 697190ce..00375b4a 100644 --- a/tests/unit/discordEvent.test.ts +++ b/tests/unit/discordEvent.test.ts @@ -3,17 +3,12 @@ import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { secretJson, secretObject } from "./secret.testdata.js"; +import { secretObject } from "./secret.testdata.js"; import supertest from "supertest"; import { describe } from "node:test"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { updateDiscord } from "../../src/api/functions/discord.js"; const ddbMock = mockClient(DynamoDBClient); -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -33,9 +28,6 @@ const app = await init(); describe("Test Events <-> Discord integration", () => { test("Happy path: valid publish submission.", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -57,9 +49,6 @@ describe("Test Events <-> Discord integration", () => { test("Happy path: do not publish repeating events.", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -87,7 +76,6 @@ describe("Test Events <-> Discord integration", () => { beforeEach(() => { (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.clearAllMocks(); vi.useFakeTimers(); }); diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index 135a692c..534fe0cc 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -1,13 +1,8 @@ import { afterAll, expect, test, beforeEach, vi } from "vitest"; -import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; import supertest from "supertest"; import { describe } from "node:test"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { EntraGroupError } from "../../src/common/errors/index.js"; // Mock required dependencies - their real impl's are defined in the beforeEach section. @@ -36,17 +31,12 @@ import { resolveEmailToOid, } from "../../src/api/functions/entraId.js"; import { EntraGroupActions } from "../../src/common/types/iam.js"; - -const smMock = mockClient(SecretsManagerClient); 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" }), - }); }); test("Modify group: Add and remove members", async () => { diff --git a/tests/unit/entraInviteUser.test.ts b/tests/unit/entraInviteUser.test.ts index 59d7e24a..21cc8f0b 100644 --- a/tests/unit/entraInviteUser.test.ts +++ b/tests/unit/entraInviteUser.test.ts @@ -1,14 +1,9 @@ import { afterAll, expect, test, beforeEach, vi } from "vitest"; -import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { secretJson, secretObject } from "./secret.testdata.js"; +import { secretObject } from "./secret.testdata.js"; import supertest from "supertest"; import { describe } from "node:test"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; vi.mock("../../src/api/functions/entraId.js", () => { return { @@ -28,7 +23,6 @@ import { } from "../../src/api/functions/entraId.js"; import { EntraInvitationError } from "../../src/common/errors/index.js"; -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -37,9 +31,6 @@ const app = await init(); describe("Test Microsoft Entra ID user invitation", () => { test("Emails must end in @illinois.edu.", async () => { - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); @@ -56,9 +47,6 @@ describe("Test Microsoft Entra ID user invitation", () => { expect(addToTenant).toHaveBeenCalled(); }); test("Happy path", async () => { - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); @@ -73,9 +61,6 @@ describe("Test Microsoft Entra ID user invitation", () => { expect(addToTenant).toHaveBeenCalled(); }); test("Happy path", async () => { - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); diff --git a/tests/unit/eventPost.test.ts b/tests/unit/eventPost.test.ts index 40400d6a..e3cb05ed 100644 --- a/tests/unit/eventPost.test.ts +++ b/tests/unit/eventPost.test.ts @@ -8,16 +8,11 @@ import { import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { secretJson, secretObject } from "./secret.testdata.js"; import supertest from "supertest"; import { marshall } from "@aws-sdk/util-dynamodb"; const ddbMock = mockClient(DynamoDBClient); -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -81,9 +76,6 @@ test("Sad path: Prevent empty body request", async () => { }); test("Sad path: Prevent specifying repeatEnds on non-repeating events", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -112,9 +104,6 @@ test("Sad path: Prevent specifying repeatEnds on non-repeating events", async () test("Sad path: Prevent specifying unknown repeat frequencies", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -144,9 +133,6 @@ test("Sad path: Prevent specifying unknown repeat frequencies", async () => { test("Happy path: Adding a non-repeating, featured, paid event", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -176,9 +162,6 @@ test("Happy path: Adding a non-repeating, featured, paid event", async () => { test("Happy path: Adding a weekly repeating, non-featured, paid event", async () => { ddbMock.on(PutItemCommand).resolves({}); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); const testJwt = createJwt(); await app.ready(); const response = await supertest(app.server) @@ -211,14 +194,8 @@ describe("ETag Lifecycle Tests", () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -299,14 +276,8 @@ describe("ETag Lifecycle Tests", () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); ddbMock.on(ScanCommand).resolves({ @@ -397,14 +368,8 @@ describe("ETag Lifecycle Tests", () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -538,8 +503,8 @@ afterAll(async () => { }); beforeEach(() => { (app as any).nodeCache.flushAll(); + (app as any).redisClient.flushdb(); ddbMock.reset(); - smMock.reset(); vi.clearAllMocks(); vi.useFakeTimers(); }); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index b6478a8b..9861a2e3 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -8,16 +8,11 @@ import { import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; -import { secretJson, secretObject } from "./secret.testdata.js"; +import { secretObject } from "./secret.testdata.js"; import supertest from "supertest"; import { marshall } from "@aws-sdk/util-dynamodb"; const ddbMock = mockClient(DynamoDBClient); -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -34,14 +29,8 @@ test("ETag should increment after event creation", async () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -122,14 +111,8 @@ test("Should return 304 Not Modified when If-None-Match header matches ETag", as // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -172,14 +155,8 @@ test("Should return 304 Not Modified when If-None-Match header matches quoted ET // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -222,14 +199,8 @@ test("Should NOT return 304 when ETag has changed", async () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -301,14 +272,8 @@ test("Should handle 304 responses for individual event endpoints", async () => { // Setup (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); - // Mock secrets manager - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); - // Mock successful DynamoDB operations ddbMock.on(PutItemCommand).resolves({}); @@ -378,6 +343,5 @@ afterAll(async () => { beforeEach(() => { (app as any).nodeCache.flushAll(); ddbMock.reset(); - smMock.reset(); vi.useFakeTimers(); }); diff --git a/tests/unit/linkry.test.ts b/tests/unit/linkry.test.ts index 23b655d7..a97a14de 100644 --- a/tests/unit/linkry.test.ts +++ b/tests/unit/linkry.test.ts @@ -9,18 +9,12 @@ import { import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; - -import { secretJson, secretObject } from "./secret.testdata.js"; +import { secretObject } from "./secret.testdata.js"; import supertest from "supertest"; import { dynamoTableData } from "./mockLinkryData.testdata.js"; import { genericConfig } from "../../src/common/config.js"; const ddbMock = mockClient(DynamoDBClient); -const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); @@ -43,24 +37,14 @@ const app = await init(); (app as any).nodeCache.flushAll(); ddbMock.reset(); -smMock.reset(); vi.useFakeTimers(); -// Mock secrets manager -smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, -}); - const adminJwt = createJwt(undefined, ["LINKS_ADMIN"], "test@gmail.com"); beforeEach(() => { ddbMock.reset(); + (app as any).redisClient.flushdb(); }); -// Get Link -beforeEach(() => { - ddbMock.reset(); -}); -// Get Link test("Happy path: Fetch all linkry redirects with admin roles", async () => { ddbMock diff --git a/tests/unit/mobileWallet.test.ts b/tests/unit/mobileWallet.test.ts index 87ee16e2..c6ee20f5 100644 --- a/tests/unit/mobileWallet.test.ts +++ b/tests/unit/mobileWallet.test.ts @@ -1,16 +1,10 @@ import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import init from "../../src/api/index.js"; import { EntraFetchError } from "../../src/common/errors/index.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import { mockClient } from "aws-sdk-client-mock"; -import { secretJson } from "./secret.testdata.js"; import { v4 as uuidv4 } from "uuid"; -const smMock = mockClient(SecretsManagerClient); const sqsMock = mockClient(SQSClient); vi.mock("../../src/api/functions/entraId.js", () => { @@ -70,8 +64,5 @@ describe("Mobile wallet pass issuance", async () => { beforeEach(() => { (app as any).nodeCache.flushAll(); vi.clearAllMocks(); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); }); }); diff --git a/tests/unit/roomRequests.test.ts b/tests/unit/roomRequests.test.ts index d4f0fda8..67dd763b 100644 --- a/tests/unit/roomRequests.test.ts +++ b/tests/unit/roomRequests.test.ts @@ -1,11 +1,6 @@ import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import init from "../../src/api/index.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { mockClient } from "aws-sdk-client-mock"; -import { secretJson } from "./secret.testdata.js"; import { DynamoDBClient, PutItemCommand, @@ -22,7 +17,6 @@ import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { AvailableSQSFunctions } from "../../src/common/types/sqsMessage.js"; import { RoomRequestStatus } from "../../src/common/types/roomRequest.js"; -const smMock = mockClient(SecretsManagerClient); const ddbMock = mockClient(DynamoDBClient); const sqsMock = mockClient(SQSClient); @@ -440,9 +434,6 @@ describe("Test Room Request Creation", async () => { ddbMock.reset(); sqsMock.reset(); vi.clearAllMocks(); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); }); test("Unauthenticated access is rejected", async () => { await app.ready(); diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index 978bc25f..3357a1a9 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -9,6 +9,7 @@ const secretObject = { acm_passkit_signerCert_base64: "", acm_passkit_signerKey_base64: "", apple_signing_cert_base64: "", + redis_url: "", } as SecretConfig & { jwt_key: string }; const secretJson = JSON.stringify(secretObject); diff --git a/tests/unit/stripe.test.ts b/tests/unit/stripe.test.ts index 3a64e87e..e1a2dcf2 100644 --- a/tests/unit/stripe.test.ts +++ b/tests/unit/stripe.test.ts @@ -1,9 +1,5 @@ import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import init from "../../src/api/index.js"; -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; import { mockClient } from "aws-sdk-client-mock"; import { secretJson } from "./secret.testdata.js"; import { @@ -19,7 +15,6 @@ import { v4 as uuidv4 } from "uuid"; import { marshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "../../src/common/config.js"; -const smMock = mockClient(SecretsManagerClient); const ddbMock = mockClient(DynamoDBClient); const linkId = uuidv4(); const productId = uuidv4(); @@ -84,9 +79,6 @@ describe("Test Stripe link creation", async () => { expect(response.statusCode).toBe(400); }); test("Test body validation 1", async () => { - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); ddbMock.on(PutItemCommand).rejects(); const testJwt = createJwt(); await app.ready(); @@ -108,12 +100,8 @@ describe("Test Stripe link creation", async () => { "body/invoiceId String must contain at least 1 character(s), body/invoiceAmountUsd Number must be greater than or equal to 50, body/contactName String must contain at least 1 character(s), body/contactEmail Required", }); expect(ddbMock.calls().length).toEqual(0); - expect(smMock.calls().length).toEqual(0); }); test("Test body validation 2", async () => { - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); ddbMock.on(PutItemCommand).rejects(); const testJwt = createJwt(); await app.ready(); @@ -135,7 +123,6 @@ describe("Test Stripe link creation", async () => { message: "body/contactEmail Invalid email", }); expect(ddbMock.calls().length).toEqual(0); - expect(smMock.calls().length).toEqual(0); }); test("POST happy path", async () => { const invoicePayload = { @@ -158,7 +145,6 @@ describe("Test Stripe link creation", async () => { link: `https://buy.stripe.com/${linkId}`, }); expect(ddbMock.calls().length).toEqual(1); - expect(smMock.calls().length).toEqual(1); }); test("Unauthenticated GET access (missing token)", async () => { await app.ready(); @@ -262,8 +248,5 @@ describe("Test Stripe link creation", async () => { beforeEach(() => { (app as any).nodeCache.flushAll(); vi.clearAllMocks(); - smMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, - }); }); }); diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index c3e24f80..254ab9c5 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -525,4 +525,5 @@ beforeEach(() => { ddbMock.reset(); vi.useFakeTimers(); (app as any).nodeCache.flushAll(); + (app as any).redisClient.flushdb(); }); diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index 241eec49..663e6601 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -2,12 +2,17 @@ import "zod-openapi/extend"; import { vi, afterEach } from "vitest"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; +import { + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; import { mockClient } from "aws-sdk-client-mock"; import { marshall } from "@aws-sdk/util-dynamodb"; import { genericConfig } from "../../src/common/config.js"; +import { secretJson } from "./secret.testdata.js"; const ddbMock = mockClient(DynamoDBClient); - +const smMock = mockClient(SecretsManagerClient); vi.mock( import("../../src/api/functions/rateLimit.js"), async (importOriginal) => { @@ -27,7 +32,7 @@ vi.mock( const mod = await importOriginal(); return { ...mod, - getUserRoles: vi.fn(async (_, __, userEmail) => { + getUserRoles: vi.fn(async (_, userEmail) => { const mockUserRoles = { "infra-unit-test-nogrp@acm.illinois.edu": [AppRoles.TICKETS_SCANNER], "infra-unit-test-stripeonly@acm.illinois.edu": [ @@ -36,10 +41,10 @@ vi.mock( kLkvWTYwNnJfBkIK7mBi4niXXHYNR7ygbV8utlvFxjw: allAppRoles, }; - return mockUserRoles[userEmail] || []; + return mockUserRoles[userEmail as any] || []; }), - getGroupRoles: vi.fn(async (_, __, groupId) => { + getGroupRoles: vi.fn(async (_, groupId) => { const mockGroupRoles = { "0": allAppRoles, "1": [], @@ -48,7 +53,7 @@ vi.mock( LINKS_MANAGER: [AppRoles.LINKS_MANAGER], }; - return mockGroupRoles[groupId] || []; + return mockGroupRoles[groupId as any] || []; }), }; }, @@ -104,6 +109,15 @@ ddbMock.on(QueryCommand).callsFake((command) => { return Promise.reject(new Error("Table not mocked")); }); +smMock.on(GetSecretValueCommand).callsFake((command) => { + if (command.SecretId == genericConfig.ConfigSecretName) { + return Promise.resolve({ SecretString: secretJson }); + } + return Promise.reject(new Error("Secret ID not mocked")); +}); + +vi.mock("ioredis", () => import("ioredis-mock")); + let mockCacheStore = new Map(); vi.mock(import("../../src/api/functions/cache.js"), async (importOriginal) => { diff --git a/yarn.lock b/yarn.lock index 00a4e117..4635aff9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1635,6 +1635,16 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.2.tgz#1860473de7dfa1546767448f333db80cb0ff2161" integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ== +"@ioredis/as-callback@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@ioredis/as-callback/-/as-callback-3.0.0.tgz#b96c9b05e6701e85ec6a5e62fa254071b0aec97f" + integrity sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg== + +"@ioredis/commands@^1.1.1", "@ioredis/commands@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -2984,6 +2994,14 @@ resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz" integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== +"@types/ioredis-mock@^8.2.5": + version "8.2.5" + resolved "https://registry.yarnpkg.com/@types/ioredis-mock/-/ioredis-mock-8.2.5.tgz#ffbb398967d325b1ddfccc0695d14792ca188d76" + integrity sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg== + dependencies: + "@types/node" "*" + ioredis ">=5" + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -4297,6 +4315,11 @@ clsx@^2.0.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" @@ -4694,6 +4717,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -5682,6 +5710,20 @@ fdir@^6.4.4: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.4.tgz#1cfcf86f875a883e19a8fab53622cfe992e8d2f9" integrity sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg== +fengari-interop@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/fengari-interop/-/fengari-interop-0.1.3.tgz#3ad37a90e7430b69b365441e9fc0ba168942a146" + integrity sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw== + +fengari@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/fengari/-/fengari-0.1.4.tgz#72416693cd9e43bd7d809d7829ddc0578b78b0bb" + integrity sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g== + dependencies: + readline-sync "^1.4.9" + sprintf-js "^1.1.1" + tmp "^0.0.33" + fflate@^0.8.2: version "0.8.2" resolved "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz" @@ -6376,6 +6418,32 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" +ioredis-mock@^8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-8.9.0.tgz#5d694c4b81d3835e4291e0b527f947e260981779" + integrity sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw== + dependencies: + "@ioredis/as-callback" "^3.0.0" + "@ioredis/commands" "^1.2.0" + fengari "^0.1.4" + fengari-interop "^0.1.3" + semver "^7.5.4" + +ioredis@>=5, ioredis@^5.6.1: + version "5.6.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.6.1.tgz#1ed7dc9131081e77342503425afceaf7357ae599" + integrity sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ipaddr.js@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" @@ -7071,6 +7139,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz" @@ -7081,6 +7154,11 @@ lodash.includes@^4.3.0: resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz" integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz" @@ -8428,6 +8506,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +readline-sync@^1.4.9: + version "1.4.10" + resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" + integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw== + real-require@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz" @@ -8452,6 +8535,18 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" @@ -9023,6 +9118,11 @@ split2@^4.0.0: resolved "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz" integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== +sprintf-js@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" @@ -9053,6 +9153,11 @@ stackback@0.0.2: resolved "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"