Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/lambdas/expenditures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Lambda for tracking project expenditures.
| GET | /health | Health check |
| GET | /expenditures | |
| POST | /expenditures | |
| PATCH | /expenditures/{id}/status | |

## Setup

Expand Down
69 changes: 66 additions & 3 deletions apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { APIGatewayProxyResult } from 'aws-lambda';
import db from './db';
import { ExpenditureValidationUtils } from './validation-utils';
import { authenticateRequest } from './auth';
import { authenticateRequest, checkAuthorization, AuthContext } from './auth';

function requireAuth(authContext: AuthContext, level: Parameters<typeof checkAuthorization>[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<APIGatewayProxyResult> => {
try {
Expand Down Expand Up @@ -169,7 +178,61 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
},
});
}
// <<< ROUTES-END

// PATCH /expenditures/{id}/status — approve/decline (admin only)
// (dev server strips the /expenditures prefix, so match the trailing /{id}/status)
const statusSegments = normalizedPath.split('/').filter(Boolean);
if ((statusSegments.length >= 2 && statusSegments[statusSegments.length - 1] === 'status') && method === 'PATCH') {
const authContext = await authenticateRequest(event);
const authError = requireAuth(authContext, 'ADMIN');
if (authError) return authError;

const id = statusSegments[statusSegments.length - 2];
if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) {
return json(400, { message: 'id must be a positive integer' });
}

const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};

// Only 'approved' or 'denied' may be set through this endpoint
const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status);
if (statusResult instanceof Error) {
return json(400, { message: statusResult.message });
}

// make sure expenditure exists
const expenditure = await db
.selectFrom('branch.expenditures')
.where('expenditure_id', '=', Number(id))
.selectAll()
.executeTakeFirst();

if (!expenditure) {
return json(404, { message: 'Expenditure not found' });
}

// update
await db
.updateTable('branch.expenditures')
.set({ status: statusResult })
.where('expenditure_id', '=', Number(id))
.execute();

// get updated expenditure
const updated = await db
.selectFrom('branch.expenditures')
.where('expenditure_id', '=', Number(id))
.selectAll()
.executeTakeFirst();

return json(200, {
ok: true,
route: 'PATCH /expenditures/{id}/status',
pathParams: { id },
body: { expenditureId: updated!.expenditure_id, status: updated!.status },
});
}
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand All @@ -185,7 +248,7 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
},
body: JSON.stringify(body)
};
Expand Down
34 changes: 34 additions & 0 deletions apps/backend/lambdas/expenditures/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,37 @@ paths:
responses:
'201':
description: Success

/expenditures/{id}/status:
patch:
summary: PATCH /expenditures/{id}/status
description: Approve or decline an expenditure. Admin only.
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- status
properties:
status:
type: string
enum: [approved, denied]
responses:
'200':
description: Expenditure status updated
'400':
description: Invalid id or status
'401':
description: Authentication required
'403':
description: Admin access required
'404':
description: Expenditure not found
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,24 @@ import { Pool } from 'pg';
jest.mock('../auth');

import { handler } from '../handler';
import { authenticateRequest } from '../auth';
import { authenticateRequest, checkAuthorization } from '../auth';

const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction<typeof authenticateRequest>;
const mockCheckAuthorization = checkAuthorization as jest.MockedFunction<typeof checkAuthorization>;

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') {
const isAdmin = authContext.user.isAdmin ?? false;
return { allowed: isAdmin, reason: isAdmin ? undefined : 'Admin access required' };
}
if (requiredAccess === 'ADMIN_OR_SELF') {
const allowed = (authContext.user.isAdmin ?? false) || 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',
Expand Down Expand Up @@ -42,6 +57,15 @@ function getEvent(path: string, queryStringParameters?: Record<string, string>)
};
}

function patchStatusEvent(id: number | string, body: Record<string, unknown>) {
return {
rawPath: `/expenditures/${id}/status`,
requestContext: { http: { method: 'PATCH' } },
headers: { Authorization: 'Bearer fake-token' },
body: JSON.stringify(body),
};
}

// Auth contexts based on seed data
const adminUser = {
isAuthenticated: true as const,
Expand Down Expand Up @@ -402,4 +426,68 @@ describe('Expenditures integration tests', () => {
expect(res.statusCode).toBe(400);
});
});

describe('PATCH /expenditures/{id}/status — approve/decline', () => {
async function getStatus(id: number): Promise<string | undefined> {
const result = await pool.query('SELECT status FROM branch.expenditures WHERE expenditure_id = $1', [id]);
return result.rows[0]?.status;
}

test('200: admin approves a pending expenditure', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(patchStatusEvent(1, { status: 'approved' }));

expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).body.status).toBe('approved');
// confirms it persisted to the database
expect(await getStatus(1)).toBe('approved');
});

test('200: admin declines a pending expenditure', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(patchStatusEvent(1, { status: 'denied' }));

expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).body.status).toBe('denied');
expect(await getStatus(1)).toBe('denied');
});

test('401: unauthenticated request is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false });
const res = await handler(patchStatusEvent(1, { status: 'approved' }));

expect(res.statusCode).toBe(401);
expect(await getStatus(1)).toBe('pending');
});

test('403: non-admin user is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue(staffUser);
const res = await handler(patchStatusEvent(1, { status: 'approved' }));

expect(res.statusCode).toBe(403);
expect(await getStatus(1)).toBe('pending');
});

test('404: expenditure not found', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(patchStatusEvent(9999, { status: 'approved' }));

expect(res.statusCode).toBe(404);
});

test('400: status not valid is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(patchStatusEvent(1, { status: 'pend' }));

expect(res.statusCode).toBe(400);
expect(await getStatus(1)).toBe('pending');
});

test('400: invalid id is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(patchStatusEvent('abc', { status: 'approved' }));

expect(res.statusCode).toBe(400);
});
});
});
128 changes: 127 additions & 1 deletion apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,25 @@ jest.mock('../auth');

import { handler } from '../handler';
import db from '../db';
import { authenticateRequest } from '../auth';
import { authenticateRequest, checkAuthorization } from '../auth';

const mockDb = db as any;
const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction<typeof authenticateRequest>;
const mockCheckAuthorization = checkAuthorization as jest.MockedFunction<typeof checkAuthorization>;

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') {
const isAdmin = authContext.user.isAdmin ?? false;
return { allowed: isAdmin, reason: isAdmin ? undefined : 'Admin access required' };
}
if (requiredAccess === 'ADMIN_OR_SELF') {
const allowed = (authContext.user.isAdmin ?? false) || 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<string, unknown>) {
Expand Down Expand Up @@ -590,3 +605,114 @@ describe('POST /expenditures unit tests', () => {
});
});
});

describe('PATCH /expenditures/{id}/status unit tests', () => {
function patchStatusEvent(id: string | number, body: unknown) {
return {
rawPath: `/expenditures/${id}/status`,
requestContext: { http: { method: 'PATCH' } },
headers: { Authorization: 'Bearer fake-token' },
body: typeof body === 'string' ? body : JSON.stringify(body),
};
}

// Sets up selectFrom (existing + updated lookups) and updateTable chains.
function mockExpenditureForPatch(existing: Record<string, unknown> | null, updated?: Record<string, unknown>) {
mockDb.selectFrom.mockReturnValue({
where: jest.fn().mockReturnValue({
selectAll: jest.fn().mockReturnValue({
executeTakeFirst: (jest.fn() as any)
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(updated ?? existing),
}),
}),
});

mockDb.updateTable.mockReturnValue({
set: jest.fn().mockReturnValue({
where: jest.fn().mockReturnValue({
execute: (jest.fn() as any).mockResolvedValue(undefined),
}),
}),
});
}

beforeEach(() => {
jest.clearAllMocks();
mockAuthenticateRequest.mockResolvedValue(adminAuthContext);
});

test('200: admin approves an expenditure', async () => {
mockExpenditureForPatch(
{ expenditure_id: 5, status: 'pending' },
{ expenditure_id: 5, status: 'approved' },
);

const res = await handler(patchStatusEvent(5, { status: 'approved' }));

expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.ok).toBe(true);
expect(json.pathParams).toEqual({ id: '5' });
expect(json.body.status).toBe('approved');
});

test('200: admin declines an expenditure', async () => {
mockExpenditureForPatch(
{ expenditure_id: 5, status: 'pending' },
{ expenditure_id: 5, status: 'denied' },
);

const res = await handler(patchStatusEvent(5, { status: 'denied' }));

expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).body.status).toBe('denied');
});

test('401: unauthenticated request', async () => {
mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false });

const res = await handler(patchStatusEvent(5, { status: 'approved' }));

expect(res.statusCode).toBe(401);
});

test('403: authenticated non-admin is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue({
isAuthenticated: true,
user: { cognitoSub: 'staff-sub', userId: 2, email: 'staff@example.com', isAdmin: false },
});

const res = await handler(patchStatusEvent(5, { status: 'approved' }));

expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).message).toContain('Admin');
});

test('400: invalid id', async () => {
const res = await handler(patchStatusEvent('abc', { status: 'approved' }));
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).message).toContain('id');
});

test('400: missing status', async () => {
const res = await handler(patchStatusEvent(5, {}));
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).message).toContain('status is required');
});

test('400: status not valid', async () => {
const res = await handler(patchStatusEvent(5, { status: 'pend' }));
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).message).toContain('status must be one of');
});

test('404: expenditure not found', async () => {
mockExpenditureForPatch(null);

const res = await handler(patchStatusEvent(999, { status: 'approved' }));

expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body).message).toContain('not found');
});
});
Loading
Loading