diff --git a/apps/backend/lambdas/users/auth.ts b/apps/backend/lambdas/users/auth.ts new file mode 100644 index 0000000..0fd2e84 --- /dev/null +++ b/apps/backend/lambdas/users/auth.ts @@ -0,0 +1,189 @@ +import { APIGatewayProxyEvent } from 'aws-lambda'; +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import db from './db'; + +// Load from environment variables +const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; +const COGNITO_REGION = process.env.AWS_REGION || 'us-east-2'; +const COGNITO_CLIENT_ID = process.env.COGNITO_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; +} + +/** + * Extract JWT token from Authorization header + */ +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; +} + +/** + * Verify and decode Cognito JWT token, then load user from database + */ +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) { + console.warn('User authenticated with Cognito but not found in database:', payload.sub); + return { isAuthenticated: false }; + } + + const user: AuthenticatedUser = { + cognitoSub: payload.sub, + userId: dbUser.user_id, + email: payload.email as string | undefined, + isAdmin: dbUser.is_admin === true, + cognitoGroups: payload['cognito:groups'] as string[] | undefined, + }; + + if (user.cognitoGroups?.includes('Admins')) { + user.isAdmin = true; + } + + return { + user, + isAuthenticated: true, + }; + } catch (error) { + console.error('Token verification failed:', error); + return { isAuthenticated: false }; + } +} + +/** + * Authorization helpers for different access levels + */ +export type AccessLevel = 'PUBLIC' | 'AUTHENTICATED' | 'ADMIN' | 'SELF' | 'ADMIN_OR_SELF'; + +export interface AuthorizationCheck { + allowed: boolean; + reason?: string; +} + +/** + * Check if user is authorized for a given access level + * @param authContext - The authentication context + * @param requiredAccess - Required access level + * @param resourceUserId - The user_id of the resource being accessed (for SELF/ADMIN_OR_SELF checks) + */ +export function checkAuthorization( + authContext: AuthContext, + requiredAccess: AccessLevel, + resourceUserId?: number | string +): AuthorizationCheck { + if (requiredAccess === 'PUBLIC') { + return { allowed: true }; + } + + // All other access levels require authentication + if (!authContext.isAuthenticated || !authContext.user) { + return { + allowed: false, + reason: 'Authentication required' + }; + } + + const { user } = authContext; + + switch (requiredAccess) { + case 'AUTHENTICATED': + return { allowed: true }; + + case 'ADMIN': + if (!user.isAdmin) { + return { + allowed: false, + reason: 'Admin access required' + }; + } + return { allowed: true }; + + case 'SELF': + if (!resourceUserId) { + return { + allowed: false, + reason: 'Resource user ID required for SELF access check' + }; + } + if (user.userId !== Number(resourceUserId)) { + return { + allowed: false, + reason: 'Can only access own resources' + }; + } + return { allowed: true }; + + case 'ADMIN_OR_SELF': + if (!resourceUserId) { + return { + allowed: false, + reason: 'Resource user ID required for ADMIN_OR_SELF access check' + }; + } + // Admin can access anything, or user can access their own resources + if (user.isAdmin || user.userId === Number(resourceUserId)) { + return { allowed: true }; + } + return { + allowed: false, + reason: 'Admin access or resource ownership required' + }; + + default: + return { + allowed: false, + reason: 'Unknown access level' + }; + } +} \ No newline at end of file diff --git a/apps/backend/lambdas/users/db-types.d.ts b/apps/backend/lambdas/users/db-types.d.ts index a44eaf8..5ca734e 100644 --- a/apps/backend/lambdas/users/db-types.d.ts +++ b/apps/backend/lambdas/users/db-types.d.ts @@ -61,6 +61,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/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 20f14f0..2f7eaa7 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,5 +1,15 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import db from './db' +import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; + +function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { + const authCheck = checkAuthorization(authContext, level, resourceUserId); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } +} export const handler = async (event: any): Promise => { @@ -21,12 +31,18 @@ export const handler = async (event: any): Promise => { return json(200, { ok: true, timestamp: new Date().toISOString() }); } + const authContext: AuthContext = await authenticateRequest(event); + + // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here // GET /users if ((normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + // TODO: Add your business logic here const queryParams = event.queryStringParameters || {}; const page = queryParams.page ? parseInt(queryParams.page, 10) : null; @@ -72,7 +88,10 @@ export const handler = async (event: any): Promise => { // GET /{userId} if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') { - const userId = normalizedPath.split('/')[1]; + const userId = normalizedPath.split('/')[1]; + const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); + if (authError) return authError; + if (!userId) return json(400, { message: 'userId is required' }); const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); @@ -94,6 +113,9 @@ export const handler = async (event: any): Promise => { // PATCH /{userId} (dev server strips /users prefix) if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') { const userId = normalizedPath.split('/')[1]; + const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); + if (authError) return authError; + if (!userId) return json(400, { message: 'userId is required' }); const body = event.body ? JSON.parse(event.body) as Record : {}; @@ -120,7 +142,10 @@ export const handler = async (event: any): Promise => { // DELETE /users/{userId} if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { - const userId = normalizedPath.split('/')[1]; // Change from [2] to [1] + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const userId = normalizedPath.split('/')[1]; if (!userId) return json(400, { message: 'userId is required' }); const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); @@ -134,6 +159,9 @@ export const handler = async (event: any): Promise => { // POST /users if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') { + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + const body = event.body ? (JSON.parse(event.body) as Record) : {}; diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 702d664..6a7d5b8 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -489,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" @@ -502,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", @@ -1096,28 +1096,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": { @@ -1515,7 +1515,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" @@ -1528,7 +1528,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" @@ -1596,7 +1596,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": { @@ -2066,7 +2066,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": { @@ -2146,7 +2146,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": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -3646,7 +3646,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": { @@ -4763,7 +4763,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", @@ -4835,7 +4835,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", @@ -4933,7 +4933,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": { @@ -5216,7 +5216,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/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index dad172d..97d7327 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -2,11 +2,44 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; // Mock the database module BEFORE importing handler jest.mock('../db'); +jest.mock('../auth'); import { handler } from '../handler'; import db from '../db'; +import { authenticateRequest, checkAuthorization } from '../auth'; +import { before } from 'node:test'; const mockDb = db as any; +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +const mockCheckAuthorization = checkAuthorization as jest.MockedFunction; + +mockCheckAuthorization.mockImplementation((authContext, requiredAccess, resourceUserId?) => { + if (requiredAccess === 'PUBLIC') { + return { allowed: true }; + } + + if (!authContext.isAuthenticated || !authContext.user) { + return { allowed: false, reason: 'Authentication required' }; + } + + if (requiredAccess === 'ADMIN') { + return { + allowed: authContext.user.isAdmin, + reason: authContext.user.isAdmin ? undefined : 'Admin access required' + }; + } + + if (requiredAccess === 'ADMIN_OR_SELF') { + const allowed = authContext.user.isAdmin || authContext.user.userId === Number(resourceUserId); + return { + allowed, + reason: allowed ? undefined : 'Admin access or resource ownership required' + }; + } + + return { allowed: false, reason: 'Unknown access level' }; +}); + // Helper function to create a POST event function postEvent(body: Record) { @@ -21,12 +54,130 @@ function postEvent(body: Record) { }; } +function mockAdminAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { + cognitoSub: 'admin-123', + userId: 1, + email: 'admin@example.com', + isAdmin: true, + }, + }); +} + +function mockRegularUserAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { + cognitoSub: 'user-123', + userId: 2, + email: 'user@example.com', + isAdmin: false, + }, + }); +} + +function mockNoAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: false, + }); +} + describe('POST /users unit tests', () => { beforeEach(() => { jest.clearAllMocks(); }); + describe('Authentication', () => { + test('401: unauthenticated user cannot create users', async () => { + mockNoAuth(); + + const res = await handler( + postEvent({ + name: 'John Doe', + email: 'john@example.com', + isAdmin: false, + }) + ); + + expect(res.statusCode).toBe(401); + const json = JSON.parse(res.body); + expect(json.message).toBe('Authentication required'); + }); + + test('403: regular user cannot create users', async () => { + mockRegularUserAuth(); + + const res = await handler( + postEvent({ + name: 'John Doe', + email: 'john@example.com', + isAdmin: false, + }) + ); + + expect(res.statusCode).toBe(403); + const json = JSON.parse(res.body); + expect(json.message).toBeDefined(); + }); + + test('401: unauthenticated user cannot view all users', async () => { + mockNoAuth(); + + const res = await handler({ + rawPath: '/users', + requestContext: { http: { method: 'GET' } }, + body: null, + }); + + expect(res.statusCode).toBe(401); + const json = JSON.parse(res.body); + expect(json.message).toBe('Authentication required'); + }); + + test('401: unauthenticated user cannot view specific user', async () => { + mockNoAuth(); + + const res = await handler({ + rawPath: '/1', + requestContext: { http: { method: 'GET' } }, + body: null, + }); + + expect(res.statusCode).toBe(401); + }); + + test('401: unauthenticated user cannot update users', async () => { + mockNoAuth(); + + const res = await handler({ + rawPath: '/1', + requestContext: { http: { method: 'PATCH' } }, + body: JSON.stringify({ name: 'New Name' }), + }); + + expect(res.statusCode).toBe(401); + }); + + test('401: unauthenticated user cannot delete users', async () => { + mockNoAuth(); + + const res = await handler({ + rawPath: '/1', + requestContext: { http: { method: 'DELETE' } }, + body: null, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('Input Validation', () => { + beforeEach(() => { + mockAdminAuth(); + }) test('400: missing email field', async () => { const res = await handler( postEvent({ @@ -117,6 +268,10 @@ describe('POST /users unit tests', () => { }); describe('Success Cases', () => { + beforeEach(() => { + mockAdminAuth(); + }); + test('201: successful POST returns 201 status and correct response shape', async () => { // Setup mocks for successful user creation // Mock the email check to return null (user doesn't exist) diff --git a/apps/backend/lambdas/users/test/users.test.ts b/apps/backend/lambdas/users/test/users.test.ts index 2af45e9..a779607 100644 --- a/apps/backend/lambdas/users/test/users.test.ts +++ b/apps/backend/lambdas/users/test/users.test.ts @@ -1,6 +1,26 @@ import fs from 'fs'; import path from 'path'; import { Pool } from 'pg'; +import { handler } from '../handler'; +import { authenticateRequest, checkAuthorization } from '../auth'; + + +jest.mock('../auth'); + +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +const mockCheckAuthorization = checkAuthorization as jest.MockedFunction; + + +mockCheckAuthorization.mockImplementation((authContext, requiredAccess, resourceUserId?) => { + if (requiredAccess === 'PUBLIC') return { allowed: true }; + if (!authContext.isAuthenticated || !authContext.user) return { allowed: false, reason: 'Authentication required' }; + if (requiredAccess === 'ADMIN') return { allowed: authContext.user.isAdmin, reason: authContext.user.isAdmin ? undefined : 'Admin access required' }; + if (requiredAccess === 'ADMIN_OR_SELF') { + const allowed = authContext.user.isAdmin || authContext.user.userId === Number(resourceUserId); + return { allowed, reason: allowed ? undefined : 'Admin access or resource ownership required' }; + } + return { allowed: false, reason: 'Unknown access level' }; +}); const pool = new Pool({ host: 'localhost', @@ -15,70 +35,162 @@ const seedSqlPath = path.resolve(__dirname, '../../../db/db_setup.sql'); const seedSql = fs.readFileSync(seedSqlPath, 'utf8'); beforeEach(async () => { - const client = await pool.connect(); + jest.clearAllMocks(); try { - await client.query(seedSql); - } finally { - client.release(); + const client = await pool.connect(); + try { + await client.query(seedSql); + } finally { + client.release(); + } + } catch (error) { + console.error('Database connection error:', error); + throw error; } }); afterAll(async () => { await pool.end(); + await new Promise(resolve => setTimeout(resolve, 500)); }); + +function createEvent(options: { + method: string; + path: string; + body?: any; + queryStringParameters?: Record; +}) { + return { + rawPath: options.path, + path: options.path, + requestContext: { + http: { + method: options.method, + }, + }, + httpMethod: options.method, + body: options.body ? JSON.stringify(options.body) : null, + queryStringParameters: options.queryStringParameters || null, + headers: {}, + }; +} +function mockAdminAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { + cognitoSub: 'admin-123', + userId: 1, + email: 'admin@example.com', + isAdmin: true, + }, + }); +} + +function mockRegularUserAuth(userId: number = 2) { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: true, + user: { + cognitoSub: `user-${userId}`, + userId, + email: 'user@example.com', + isAdmin: false, + }, + }); +} + +function mockNoAuth() { + mockAuthenticateRequest.mockResolvedValue({ + isAuthenticated: false, + }); +} + test("health test 🌞", async () => { - let res = await fetch("http://localhost:3000/users/health") - expect(res.status).toBe(200); + mockNoAuth(); + + const event = createEvent({ + method: 'GET', + path: '/health', + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); }); test("patch user test 🌞", async () => { - const originalRes = await fetch("http://localhost:3000/users/1"); - expect(originalRes.status).toBe(200); - const originalBody = await originalRes.json().then(r => r.body); + mockAdminAuth(); + + const getEvent = createEvent({ + method: 'GET', + path: '/1', + }) + + const originalRes = await handler(getEvent); + expect(originalRes.statusCode).toBe(200); + const originalBody = JSON.parse(originalRes.body).body; try { - let res = await fetch("http://localhost:3000/users/1", { - method: "PATCH", - body: JSON.stringify({ + const patchEvent = createEvent({ + method: 'PATCH', + path: '/1', + body: { name: "John Branch", email: "mrbranch@example.com", isAdmin: false - }) - }) - expect(res.status).toBe(200); - let body = await res.json().then(r => r.body); + }, + }); + + const res = await handler(patchEvent); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body).body; expect(body.email).toBe("mrbranch@example.com"); expect(body.name).toBe("John Branch"); expect(body.isAdmin).toBe(false); } finally { - await fetch("http://localhost:3000/users/1", { - method: "PATCH", - body: JSON.stringify({ + // Restore original + const restoreEvent = createEvent({ + method: 'PATCH', + path: '/1', + body: { name: originalBody.name, email: originalBody.email, isAdmin: originalBody.isAdmin - }) + }, }); + await handler(restoreEvent); } }); test("patch user 404 test 🌞", async () => { - let res = await fetch("http://localhost:3000/users/4", { - method: "PATCH", - body: JSON.stringify({ + mockAdminAuth(); + + const event = createEvent({ + method: 'PATCH', + path: '/4', + body: { name: "John Doe", email: "john.doe@example.com" - }) - }) - expect(res.status).toBe(404); + }, + }); + + const res = await handler(event); + expect(res.statusCode).toBe(404); }); test("get users test", async () => { - let res = await fetch("http://localhost:3000/users") - expect(res.status).toBe(200); - let body = await res.json(); + mockAdminAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); console.log(body); expect(body.users).toBeDefined(); expect(Array.isArray(body.users)).toBe(true); @@ -103,10 +215,20 @@ test("get users test", async () => { expect(thirdUser.user_id).toBe(3); }); + test("get users with correct pagnation", async () => { - let res = await fetch("http://localhost:3000/users?page=1&limit=1") - expect(res.status).toBe(200); - let body = await res.json(); + mockAdminAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + queryStringParameters: { page: '1', limit: '1' }, + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); console.log(body); expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); @@ -124,10 +246,20 @@ test("get users with correct pagnation", async () => { expect(firstUser.user_id).toBe(1); }); + test("get users with only page", async () => { - let res = await fetch("http://localhost:3000/users?page=1") - expect(res.status).toBe(200); - let body = await res.json(); + mockAdminAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + queryStringParameters: { page: '1' }, + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); console.log(body); expect(body.pagination).toBeUndefined(); @@ -136,10 +268,20 @@ test("get users with only page", async () => { expect(body.users.length).toBe(3); }); + test("get users with only limit", async () => { - let res = await fetch("http://localhost:3000/users?limit=1") - expect(res.status).toBe(200); - let body = await res.json(); + mockAdminAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + queryStringParameters: { limit: '1' }, + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); console.log(body); expect(body.pagination).toBeUndefined(); @@ -149,9 +291,18 @@ test("get users with only limit", async () => { }); test("get users with limit above total user", async () => { - let res = await fetch("http://localhost:3000/users?page=1&limit=100") - expect(res.status).toBe(200); - let body = await res.json(); + mockAdminAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + queryStringParameters: { page: '1', limit: '100' }, + }); + + const res = await handler(event); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); console.log(body); expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); @@ -163,25 +314,49 @@ test("get users with limit above total user", async () => { expect(body.users.length).toBe(3); }); +// Wrong path test("get users error", async () => { - let res = await fetch("http://localhost:3000/user") - expect(res.status).toBe(404); + mockNoAuth(); + + const event = createEvent({ + method: 'GET', + path: '/user', + }); + + const res = await handler(event); + expect(res.statusCode).toBe(401); +}); + +// regular user can't see all users +test("regular user cannot view all users", async () => { + mockRegularUserAuth(); + + const event = createEvent({ + method: 'GET', + path: '/users', + }); + + const res = await handler(event); + expect(res.statusCode).toBe(403); }); test("POST user success case", async () => { - let res = await fetch("http://localhost:3000/users", { - method: "POST", - body: JSON.stringify({ + mockAdminAuth(); + + const event = createEvent({ + method: 'POST', + path: '/users', + body: { name: "Jane Branch", email: "jane@branch.com", isAdmin: true - }) + }, }); - expect(res.status).toBe(201); - - let body = await res.json(); + const res = await handler(event); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); expect(body.ok).toBe(true); expect(body.body.name).toBe("Jane Branch"); expect(body.body.email).toBe("jane@branch.com"); @@ -189,98 +364,186 @@ test("POST user success case", async () => { }); test("POST user 400 case when invalid email is sent", async () => { - let res = await fetch("http://localhost:3000/users", { - method: "POST", - body: JSON.stringify({ + mockAdminAuth(); + + const event = createEvent({ + method: 'POST', + path: '/users', + body: { name: "Invalid User", email: "", isAdmin: false - }) + }, }); - expect(res.status).toBe(400); + const res = await handler(event); + expect(res.statusCode).toBe(400); }); test("POST user 400 case when request sent with missing fields", async () => { - let res = await fetch("http://localhost:3000/users", { - method: "POST", - body: JSON.stringify({ + mockAdminAuth(); + + const event = createEvent({ + method: 'POST', + path: '/users', + body: { name: "Invalid User", - }) // missing email and admin fields + }, }); - expect(res.status).toBe(400); + const res = await handler(event); + expect(res.statusCode).toBe(400); }); -test("delete user test 🌞", async () => { - let res = await fetch("http://localhost:3000/users/1", { - method: "DELETE" +// regular user can't make new users + +test("regular user cannot create users", async () => { + mockRegularUserAuth(); + + const event = createEvent({ + method: 'POST', + path: '/users', + body: { name: "Test", email: "test@example.com", isAdmin: false }, }); + + const res = await handler(event); + expect(res.statusCode).toBe(403); +}); - expect(res.status).toBe(200); - let body = await res.json(); +test("delete user test 🌞", async () => { + mockAdminAuth(); + + const deleteEvent = createEvent({ + method: 'DELETE', + path: '/1', + }); + const res = await handler(deleteEvent); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); expect(body.ok).toBe(true); expect(body.route).toBe("DELETE /users/{userId}"); expect(body.pathParams.userId).toBe("1"); - let getRes = await fetch("http://localhost:3000/users/1"); - expect(getRes.status).toBe(404); - let getbody = await getRes.json(); - expect(getbody.message).toBe('User not found'); + // Verify user is deleted + const getEvent = createEvent({ + method: 'GET', + path: '/1', + }); + const getRes = await handler(getEvent); + expect(getRes.statusCode).toBe(404); + + const getBody = JSON.parse(getRes.body); + expect(getBody.message).toBe('User not found'); }); + test("delete user 404 test 🌞", async () => { - let res = await fetch("http://localhost:3000/users/9999", { - method: "DELETE" + mockAdminAuth(); + + const event = createEvent({ + method: 'DELETE', + path: '/9999', }); - expect(res.status).toBe(404); - let body = await res.json(); + const res = await handler(event); + expect(res.statusCode).toBe(404); + + const body = JSON.parse(res.body); expect(body.message).toBe('User not found'); }); + test("delete same user twice returns 404 on second attempt", async () => { - let res1 = await fetch("http://localhost:3000/users/1", { - method: "DELETE" + mockAdminAuth(); + + const event1 = createEvent({ + method: 'DELETE', + path: '/1', }); - expect(res1.status).toBe(200); + const res1 = await handler(event1); + expect(res1.statusCode).toBe(200); - let res2 = await fetch("http://localhost:3000/users/1", { - method: "DELETE" + const event2 = createEvent({ + method: 'DELETE', + path: '/1', }); - expect(res2.status).toBe(404); - let body = await res2.json(); + const res2 = await handler(event2); + expect(res2.statusCode).toBe(404); + + const body = JSON.parse(res2.body); expect(body.message).toBe('User not found'); }); + test("delete multiple users", async () => { - let res1 = await fetch("http://localhost:3000/users/1", { - method: "DELETE" + mockAdminAuth(); + + const event1 = createEvent({ + method: 'DELETE', + path: '/1', }); - expect(res1.status).toBe(200); + const res1 = await handler(event1); + expect(res1.statusCode).toBe(200); - let res2 = await fetch("http://localhost:3000/users/2", { - method: "DELETE" + const event2 = createEvent({ + method: 'DELETE', + path: '/2', }); - expect(res2.status).toBe(200); + const res2 = await handler(event2); + expect(res2.statusCode).toBe(200); - let check1 = await fetch("http://localhost:3000/users/1"); - expect(check1.status).toBe(404); + // Check both are deleted + const check1Event = createEvent({ + method: 'GET', + path: '/1', + }); + const check1 = await handler(check1Event); + expect(check1.statusCode).toBe(404); - let check2 = await fetch("http://localhost:3000/users/2"); - expect(check2.status).toBe(404); + const check2Event = createEvent({ + method: 'GET', + path: '/2', + }); + const check2 = await handler(check2Event); + expect(check2.statusCode).toBe(404); }); + test("delete user 1 does not affect user 2", async () => { + mockAdminAuth(); + // Delete user 1 - await fetch("http://localhost:3000/users/1", { method: "DELETE" }); + const deleteEvent = createEvent({ + method: 'DELETE', + path: '/1', + }); + await handler(deleteEvent); // User 2 should still exist - let res = await fetch("http://localhost:3000/users/2"); - expect(res.status).toBe(200); + const getEvent = createEvent({ + method: 'GET', + path: '/2', + }); + const res = await handler(getEvent); + expect(res.statusCode).toBe(200); - let body = await res.json(); + const body = JSON.parse(res.body); expect(body.body.email).toBe('renee@branch.org'); }); + +// regular user can't delete others + +test("regular user cannot delete users", async () => { + mockRegularUserAuth(); + + const event = createEvent({ + method: 'DELETE', + path: '/1', + }); + + const res = await handler(event); + expect(res.statusCode).toBe(403); +}); \ No newline at end of file diff --git a/example.env b/example.env index 211b147..e69de29 100644 --- a/example.env +++ b/example.env @@ -1,5 +0,0 @@ -NX_DB_HOST=localhost, -NX_DB_USERNAME=postgres, -NX_DB_PASSWORD=, -NX_DB_DATABASE=jumpstart, -NX_DB_PORT=5432, \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1e6e0f5..2249973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/typeorm": "^10.0.0", "@types/pg": "^8.15.5", "amazon-cognito-identity-js": "^6.3.5", + "aws-jwt-verify": "^5.1.1", "axios": "^1.5.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -49,7 +50,8 @@ "@nx/webpack": "^16.8.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.0.0", - "@types/jest": "^29.5.14", + "@types/aws-lambda": "^8.10.160", + "@types/jest": "^29.4.0", "@types/node": "^18.19.130", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", @@ -6610,6 +6612,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aws-lambda": { + "version": "8.10.160", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz", + "integrity": "sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.3", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", @@ -8607,6 +8616,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", diff --git a/package.json b/package.json index 68ff03b..f427a48 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@nestjs/typeorm": "^10.0.0", "@types/pg": "^8.15.5", "amazon-cognito-identity-js": "^6.3.5", + "aws-jwt-verify": "^5.1.1", "axios": "^1.5.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -58,7 +59,8 @@ "@nx/webpack": "^16.8.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.0.0", - "@types/jest": "^29.5.14", + "@types/aws-lambda": "^8.10.160", + "@types/jest": "^29.4.0", "@types/node": "^18.19.130", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7",