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 b2bc948..3d190d8 100644 --- a/apps/backend/lambdas/users/db-types.d.ts +++ b/apps/backend/lambdas/users/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/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 20f14f0..9abc4d4 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,5 +1,6 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import db from './db' +import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; export const handler = async (event: any): Promise => { @@ -21,12 +22,22 @@ 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 authCheck = checkAuthorization(authContext, 'ADMIN'); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } + // TODO: Add your business logic here const queryParams = event.queryStringParameters || {}; const page = queryParams.page ? parseInt(queryParams.page, 10) : null; @@ -72,6 +83,13 @@ export const handler = async (event: any): Promise => { // GET /{userId} if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') { + const authCheck = checkAuthorization(authContext, 'ADMIN_OR_SELF'); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } + const userId = normalizedPath.split('/')[1]; if (!userId) return json(400, { message: 'userId is required' }); @@ -93,6 +111,13 @@ export const handler = async (event: any): Promise => { // PATCH /{userId} (dev server strips /users prefix) if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') { + const authCheck = checkAuthorization(authContext, 'ADMIN_OR_SELF'); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } + const userId = normalizedPath.split('/')[1]; if (!userId) return json(400, { message: 'userId is required' }); const body = event.body ? JSON.parse(event.body) as Record : {}; @@ -120,6 +145,13 @@ export const handler = async (event: any): Promise => { // DELETE /users/{userId} if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { + const authCheck = checkAuthorization(authContext, 'ADMIN'); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } + const userId = normalizedPath.split('/')[1]; // Change from [2] to [1] if (!userId) return json(400, { message: 'userId is required' }); @@ -134,6 +166,13 @@ export const handler = async (event: any): Promise => { // POST /users if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') { + const authCheck = checkAuthorization(authContext, 'ADMIN'); + if (!authCheck.allowed) { + return authContext.isAuthenticated + ? json(403, { message: authCheck.reason || 'Forbidden' }) + : json(401, { message: 'Authentication required' }); + } + const body = event.body ? (JSON.parse(event.body) as Record) : {}; 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/apps/backend/lambdas/users/tsconfig.json b/apps/backend/lambdas/users/tsconfig.json index d35b2ba..e62dc53 100644 --- a/apps/backend/lambdas/users/tsconfig.json +++ b/apps/backend/lambdas/users/tsconfig.json @@ -10,6 +10,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["*.ts"], + "include": ["*.ts", "test/test-cognito.ts"], "exclude": ["node_modules", "dist", "dev-server.ts", "swagger-utils.ts"] } diff --git a/example.env b/example.env index 211b147..ba42e3b 100644 --- a/example.env +++ b/example.env @@ -2,4 +2,8 @@ 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 +NX_DB_PORT=5432, + +COGNITO_USER_POOL_ID=us-east-2_CxTueqe6g +COGNITO_CLIENT_ID=570i6ocj0882qu0ditm4vrr60f +COGNITO_REGION=us-east-2 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 081549b..429cf47 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", @@ -48,6 +49,7 @@ "@nx/vite": "^16.8.1", "@nx/webpack": "^16.8.1", "@testing-library/react": "^14.0.0", + "@types/aws-lambda": "^8.10.160", "@types/jest": "^29.4.0", "@types/node": "^18.19.130", "@types/react": "^18.2.14", @@ -6582,6 +6584,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", @@ -8587,6 +8596,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 cc75c0d..c985c52 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", @@ -57,6 +58,7 @@ "@nx/vite": "^16.8.1", "@nx/webpack": "^16.8.1", "@testing-library/react": "^14.0.0", + "@types/aws-lambda": "^8.10.160", "@types/jest": "^29.4.0", "@types/node": "^18.19.130", "@types/react": "^18.2.14", diff --git a/yarn.lock b/yarn.lock index 572aafa..9ed7921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3034,6 +3034,11 @@ resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.3.tgz" integrity sha512-0Z6Tr7wjKJIk4OUEjVUQMtyunLDy339vcMaj38Kpj6jM2OE1p3S4kXExKZ7a3uXQAPCoy3sbrP1wibDKaf39oA== +"@types/aws-lambda@^8.10.160": + version "8.10.160" + resolved "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz" + integrity sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA== + "@types/babel__core@^7.1.14", "@types/babel__core@^7.20.2": version "7.20.3" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz" @@ -4130,6 +4135,11 @@ available-typed-arrays@^1.0.5: resolved "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +aws-jwt-verify@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-5.1.1.tgz" + integrity sha512-j6whGdGJmQ27agk4ijY8RPv6itb8JLb7SCJ86fEnneTcSBrpxuwL8kLq6y5WVH95aIknyAloEqAsaOLS1J8ITQ== + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz"