From 7061d499098d373460882c3ce0d4836ed65bac3c Mon Sep 17 00:00:00 2001 From: Rayna-Yu Date: Thu, 19 Feb 2026 22:51:58 -0500 Subject: [PATCH 1/3] add aws-jwt-verifier --- apps/backend/lambdas/projects/auth.ts | 219 ++++++++++++++++++ .../lambdas/projects/package-lock.json | 42 ++-- apps/backend/lambdas/projects/package.json | 1 + 3 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 apps/backend/lambdas/projects/auth.ts diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts new file mode 100644 index 0000000..d1b8d66 --- /dev/null +++ b/apps/backend/lambdas/projects/auth.ts @@ -0,0 +1,219 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import db from './db'; + +const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!; +const COGNITO_CLIENT_ID = process.env.COGNITO_APP_CLIENT_ID!; + +// Create verifier instance lazily (only when needed) +let verifier: any = null; + +function getVerifier() { + if (!verifier) { + if (!COGNITO_USER_POOL_ID) { + throw new Error('COGNITO_USER_POOL_ID environment variable is not set'); + } + verifier = CognitoJwtVerifier.create({ + userPoolId: COGNITO_USER_POOL_ID, + tokenUse: 'access', + clientId: COGNITO_CLIENT_ID, + }); + } + return verifier; +} + +export interface AuthenticatedUser { + cognitoSub: string; + userId?: number; + email: string; + isAdmin?: boolean; + cognitoGroups?: string[]; +} + +export interface AuthContext { + user?: AuthenticatedUser; + isAuthenticated: boolean; +} + +/** + * Encode a JWT token (for testing purposes) + * Creates a mock JWT with the standard three-part format + */ +export function encodeJWT(payload: any): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64'); + const signature = ''; + + // Format: header.payload.signature + return `${header}.${body}.${signature}`; +} + +/** + * Extract and validate JWT token from Authorization header + * Format: "Bearer " + */ +export function extractTokenFromHeader(authorizationHeader: string | undefined): string | null { + if (!authorizationHeader) return null; + + const parts = authorizationHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null; + } + + return parts[1]; +} + +/** + * Decode JWT token without verification (for development/testing) + * In production, you would verify with Cognito public keys + */ +export function decodeJWT(token: string): any { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + // Decode payload (second part) + const payload = parts[1]; + const decoded = Buffer.from(payload, 'base64').toString('utf8'); + return JSON.parse(decoded); + } catch (error) { + console.error('Error decoding JWT:', error); + return null; + } +} + +/** + * Authenticate request and return user info + * This validates the token and fetches user details from database + */ +export async function authenticateRequest( + authorizationHeader: string | undefined +): Promise<{ user: AuthenticatedUser; error?: string }> { + const token = extractTokenFromHeader(authorizationHeader); + + if (!token) { + return { user: null as any, error: 'Missing or invalid Authorization header' }; + } + + const decoded = decodeJWT(token); + + if (!decoded || !decoded.sub) { + return { user: null as any, error: 'Invalid token' }; + } + + // The 'sub' claim contains the Cognito user ID + const cognitoSub = decoded.sub; + const email = decoded.email; + + try { + // Fetch user from database + const dbUser = await db + .selectFrom('branch.users') + .where('cognito_sub', '=', cognitoSub) + .selectAll() + .executeTakeFirst(); + + if (!dbUser) { + return { user: null as any, error: 'User not found in database' }; + } + + const user: AuthenticatedUser = { + cognitoSub, + email: email || dbUser.email, + userId: dbUser.user_id, + isAdmin: dbUser.is_admin || false, + }; + + return { user }; + } catch (error) { + console.error('Database lookup error:', error); + return { user: null as any, error: 'Failed to authenticate user' }; + } +} + +/** + * Check if user has access to a project + * Access granted if: + * - User is admin, OR + * - User is a member of the project + */ +export async function canAccessProject(userId: number, projectId: number): Promise { + try { + // Check if user is admin + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + if (user?.is_admin) return true; + + // Check if user is a member of the project + const membership = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', userId) + .where('project_id', '=', projectId) + .selectAll() + .executeTakeFirst(); + + return !!membership; + } catch (error) { + console.error('Error checking project access:', error); + return false; + } +} + +/** + * Check if user has edit access to a project + * Access granted if: + * - User is admin, OR + * - User is a PI, Accountant, or Admin in the project + */ +export async function canEditProject(userId: number, projectId: number): Promise { + try { + // Check if user is admin + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + if (user?.is_admin) return true; + + // Check if user has edit role in project + const membership = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', userId) + .where('project_id', '=', projectId) + .select('role') + .executeTakeFirst(); + + if (!membership) return false; + + // PI, Accountant, and Admin can edit + const editableRoles = ['PI', 'Accountant', 'Admin']; + return editableRoles.includes(membership.role); + } catch (error) { + console.error('Error checking edit access:', error); + return false; + } +} + +/** + * Check if user can create projects + * Access granted if: + * - User is admin + */ +export async function canCreateProject(userId: number): Promise { + try { + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + return user?.is_admin || false; + } catch (error) { + console.error('Error checking create access:', error); + return false; + } +} diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index 84e231e..7a576a8 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -499,7 +500,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -1070,7 +1071,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1146,28 +1147,28 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1565,7 +1566,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1578,7 +1579,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -1646,7 +1647,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -1663,6 +1664,15 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-jwt-verify": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.1.1.tgz", + "integrity": "sha512-j6whGdGJmQ27agk4ijY8RPv6itb8JLb7SCJ86fEnneTcSBrpxuwL8kLq6y5WVH95aIknyAloEqAsaOLS1J8ITQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/axios": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", @@ -2116,7 +2126,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2196,7 +2206,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3694,7 +3704,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -4817,7 +4827,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -4889,7 +4899,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4987,7 +4997,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -5268,7 +5278,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index cf88f14..5c9c92d 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -21,6 +21,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", "pg": "^8.16.3" From 54f110c2209d791edb79729afe064fa3ee16eb21 Mon Sep 17 00:00:00 2001 From: Rayna-Yu Date: Sun, 8 Mar 2026 22:19:22 -0400 Subject: [PATCH 2/3] add authentication and fix tests --- apps/backend/lambdas/donors/auth.ts | 92 +++++++++++++++++++ apps/backend/lambdas/donors/db-types.d.ts | 1 + apps/backend/lambdas/donors/handler.ts | 9 +- apps/backend/lambdas/donors/package-lock.json | 42 +++++---- apps/backend/lambdas/donors/package.json | 1 + .../lambdas/donors/test/donors.test.ts | 92 ++++++++++++++++--- 6 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 apps/backend/lambdas/donors/auth.ts diff --git a/apps/backend/lambdas/donors/auth.ts b/apps/backend/lambdas/donors/auth.ts new file mode 100644 index 0000000..6b70cf4 --- /dev/null +++ b/apps/backend/lambdas/donors/auth.ts @@ -0,0 +1,92 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import db from './db'; + +const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!; +const COGNITO_CLIENT_ID = process.env.COGNITO_APP_CLIENT_ID!; + +// Create verifier instance lazily (only when needed) +let verifier: any = null; + +function getVerifier() { + if (!verifier) { + if (!COGNITO_USER_POOL_ID) { + throw new Error('COGNITO_USER_POOL_ID environment variable is not set'); + } + verifier = CognitoJwtVerifier.create({ + userPoolId: COGNITO_USER_POOL_ID, + tokenUse: 'access', + clientId: COGNITO_CLIENT_ID, + }); + } + return verifier; +} + +export interface AuthenticatedUser { + cognitoSub: string; + userId?: number; + email: string; + isAdmin?: boolean; + cognitoGroups?: string[]; +} + +export interface AuthContext { + user?: AuthenticatedUser; + isAuthenticated: boolean; +} + +/** + * Encode a JWT token + */ +function extractToken(event: any): string | null { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + + if (!authHeader) { + return null; + } + + const parts = authHeader.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { + return parts[1]; + } + + return authHeader; +} + +export async function authenticateRequest(event: any): Promise { + const token = extractToken(event); + + if (!token) { + return { isAuthenticated: false }; + } + + try { + const payload = await getVerifier().verify(token); + + const dbUser = await db + .selectFrom('branch.users') + .where('cognito_sub', '=', payload.sub) + .selectAll() + .executeTakeFirst(); + + if (!dbUser) { + return { isAuthenticated: false }; + } + + const user: AuthenticatedUser = { + cognitoSub: payload.sub, + email: payload.email, + userId: dbUser.user_id, + isAdmin: dbUser.is_admin || false, + cognitoGroups: payload['cognito:groups'] || [], + }; + + if (user.cognitoGroups?.includes('Admins')) { + user.isAdmin = true; + } + + return { user, isAuthenticated: true }; + } catch (error) { + console.error('Authentication error:', error); + return { isAuthenticated: false }; + } +} \ No newline at end of file diff --git a/apps/backend/lambdas/donors/db-types.d.ts b/apps/backend/lambdas/donors/db-types.d.ts index b2bc948..3d190d8 100644 --- a/apps/backend/lambdas/donors/db-types.d.ts +++ b/apps/backend/lambdas/donors/db-types.d.ts @@ -60,6 +60,7 @@ export interface BranchProjects { } export interface BranchUsers { + cognito_sub: string | null; created_at: Generated; email: string; is_admin: Generated; diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index 4ed420a..0b93107 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,5 +1,6 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { APIGatewayProxyResult } from 'aws-lambda'; import db from './db'; +import { authenticateRequest } from './auth'; export const handler = async (event: any): Promise => { try { @@ -15,11 +16,17 @@ export const handler = async (event: any): Promise => { return json(200, { ok: true, timestamp: new Date().toISOString() }); } + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Unauthorized' }); + } + // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here // GET /donors if (rawPath === '/' && method === 'GET') { + const donors = await db.selectFrom("branch.donors").selectAll().execute() return json(200, donors ?? []); } diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index 71009e9..a254a95 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", "pg": "^8.17.2" @@ -488,7 +489,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -501,7 +502,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1034,28 +1035,28 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1453,7 +1454,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1466,7 +1467,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -1534,7 +1535,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -1544,6 +1545,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aws-jwt-verify": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.1.1.tgz", + "integrity": "sha512-j6whGdGJmQ27agk4ijY8RPv6itb8JLb7SCJ86fEnneTcSBrpxuwL8kLq6y5WVH95aIknyAloEqAsaOLS1J8ITQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -1941,7 +1951,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2011,7 +2021,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3227,7 +3237,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -4203,7 +4213,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -4275,7 +4285,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4373,7 +4383,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -4624,7 +4634,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 804ea85..5bc3206 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -20,6 +20,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", "pg": "^8.17.2" diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index e106cab..a5f9b2d 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -1,6 +1,10 @@ import fs from 'fs'; import path from 'path'; import { Pool } from 'pg'; +import { handler } from '../handler'; +jest.mock('../auth'); +import { authenticateRequest } from '../auth'; +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; const pool = new Pool({ host: process.env.DB_HOST ?? 'localhost', @@ -14,6 +18,43 @@ const pool = new Pool({ const seedSqlPath = path.resolve(__dirname, '../../../db/db_setup.sql'); const seedSql = fs.readFileSync(seedSqlPath, 'utf8'); +const authenticatedUser = { + isAuthenticated: true, + user: { + cognitoSub: 'staff-sub', + userId: 1, + email: 'person@branch.org', + isAdmin: false, + }, +} + +const adminUser = { + isAuthenticated: true, + user: { + cognitoSub: 'admin-sub', + userId: 1, + email: 'ashley@branch.org', + isAdmin: true, + }, +} + +function createEvent(method: string, path: string, body?: any) { + return { + rawPath: path, + requestContext: { + http: { + method: method, + }, + }, + body: body ? JSON.stringify(body) : undefined, + }; +} + +function createAdminToken() { + mockAuthenticateRequest.mockResolvedValueOnce(adminUser); + return 'admin-token'; +} + describe("Donor API with data", () => { beforeEach(async () => { const client = await pool.connect(); @@ -29,19 +70,27 @@ describe("Donor API with data", () => { expect(res.status).toBe(200); }); - test("Status check for get all donors when donors exist 🌞", async () => { - let res = await fetch("http://localhost:3000/donors", { method: "GET" }); - expect(res.status).toBe(200); + test("Status check for get all donors when donors exist 🌞 - with auth", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/')); + expect(res.statusCode).toBe(200); }); - test("Content check for get all donors when donors exist 🌞", async () => { - let res = await fetch("http://localhost:3000/donors", { method: "GET" }); - let body = await res.json(); + test("Content check for get all donors when donors exist 🌞 - with auth", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/')); + const body = JSON.parse(res.body); - expect(res.status).toBe(200); + expect(res.statusCode).toBe(200); expect(Array.isArray(body)).toBe(true); expect(body.length).toBe(3); }); + + test("401 when missing authorization header", async () => { + mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false }); + const res = await handler(createEvent('GET', '/')); + expect(res.statusCode).toBe(401); + }); }); describe("Donor API when DB is empty", () => { @@ -54,19 +103,34 @@ describe("Donor API when DB is empty", () => { } }); - test("Status check for get all donors when DB is empty", async () => { - let res = await fetch("http://localhost:3000/donors", { method: "GET" }); - expect(res.status).toBe(200); + test("Status check for get all donors when DB is empty - with auth", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(adminUser); + const res = await handler( + createEvent('GET', '/') + ); + expect(res.statusCode).toBe(200); }); - test("Content check for get all donors when DB is empty", async () => { - let res = await fetch("http://localhost:3000/donors", { method: "GET" }); - let body = await res.json(); + test("Content check for get all donors when DB is empty - with auth", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(adminUser); + const res = await handler( + createEvent('GET', '/') + ); + const body = JSON.parse(res.body); - expect(res.status).toBe(200); + expect(res.statusCode).toBe(200); expect(Array.isArray(body)).toBe(true); expect(body.length).toBe(0); }); + + test("401 when missing authentication", async () => { + mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false }); + const res = await handler( + createEvent('GET', '/') + ); + + expect(res.statusCode).toBe(401); + }); }); afterAll(async () => { From c6ee7a0ffdb5c01e413bcc307a6801baee690bae Mon Sep 17 00:00:00 2001 From: Rayna-Yu Date: Sun, 8 Mar 2026 22:26:30 -0400 Subject: [PATCH 3/3] add more testing --- apps/backend/lambdas/donors/test/donors.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index a5f9b2d..a2b4c96 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -76,6 +76,12 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); }); + test("Status check for get all donors when donors exist 🌞 - with admin", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(adminUser); + const res = await handler(createEvent('GET', '/')); + expect(res.statusCode).toBe(200); + }); + test("Content check for get all donors when donors exist 🌞 - with auth", async () => { mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); const res = await handler(createEvent('GET', '/')); @@ -103,6 +109,14 @@ describe("Donor API when DB is empty", () => { } }); + test("Status check for get all donors when DB is empty - with auth", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler( + createEvent('GET', '/') + ); + expect(res.statusCode).toBe(200); + }); + test("Status check for get all donors when DB is empty - with auth", async () => { mockAuthenticateRequest.mockResolvedValueOnce(adminUser); const res = await handler(