Skip to content
Open
2 changes: 2 additions & 0 deletions apps/backend/lambdas/expenditures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Lambda for tracking project expenditures.
| GET | /health | Health check |
| GET | /expenditures | |
| POST | /expenditures | |
| GET | /expenditures/{id} | |
| DELETE | /expenditures/{id} | |
| PATCH | /expenditures/{id}/status | |

## Setup
Expand Down
104 changes: 101 additions & 3 deletions apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,105 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
},
});
}


// GET /expenditures/{id}
if (/^\/[^\/]+$/.test(normalizedPath) && method === 'GET') {
const id = normalizedPath.split('/')[1];
if (!id) return json(400, { message: 'id is required' });

if (!id || !/^\d+$/.test(id)) {
return json(400, { message: 'id must be a positive integer' });
}

const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we only allow viewing these for users in the project or admin? might be kind of a gross join but should be fine for this scale

return json(401, { message: 'Authentication required' });
}

const { user } = authContext;

const expenditure = await db.selectFrom("branch.expenditures").where("expenditure_id", "=", Number(id)).selectAll().executeTakeFirst();
if (!expenditure) return json(404, { message: 'Expenditure not found' });

if (!user.isAdmin) {
const membership = await db
.selectFrom('branch.project_memberships')
.where('project_id', '=', expenditure.project_id)
.where('user_id', '=', user.userId!)
.select('role')
.executeTakeFirst();

if (!membership) {
return json(403, { message: 'Unable to view this expenditure' });
}
}

return json(200, {
ok: true,
route: 'GET /expenditures/{id}',
pathParams: { id },
body: {
expenditureId: expenditure.expenditure_id,
projectId: expenditure.project_id,
enteredBy: expenditure.entered_by,
amount: expenditure.amount,
category: expenditure.category,
description: expenditure.description,
status: expenditure.status,
receiptUrl: expenditure.receipt_url,
spent_on: expenditure.spent_on,
createdAt: expenditure.created_at,
}
});
}

// DELETE /expenditures/{id}
if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') {
const id = normalizedPath.split('/')[1];
if (!id) return json(400, { message: 'id is required' });
if (!id || !/^\d+$/.test(id)) {
return json(400, { message: 'id must be a positive integer' });
}

const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
}
const { user } = authContext;

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

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

// (mirrors POST endpoint) Authorize: must be global admin, or PI/Accountant/Admin on this expenditure's project
if (!user.isAdmin) {
const membership = await db
.selectFrom('branch.project_memberships')
.where('project_id', '=', expenditure.project_id)
.where('user_id', '=', user.userId!)
.select('role')
.executeTakeFirst();

if (!membership || !['PI', 'Accountant', 'Admin'].includes(membership.role)) {
return json(403, { message: 'Unable to delete this expenditure' });
}
}

const deleted = await db.deleteFrom('branch.expenditures').where('expenditure_id', '=', Number(id)).execute();

if (!deleted[0] || deleted[0].numDeletedRows === 0n) {
return json(404, { message: 'Expenditure not found' });
}

return json(200, { ok: true, route: 'DELETE /expenditures/{id}', pathParams: { id } });
}

// 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);
Expand Down Expand Up @@ -232,7 +330,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
body: { expenditureId: updated!.expenditure_id, status: updated!.status },
});
}
// <<< ROUTES-END
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand All @@ -252,4 +350,4 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult {
},
body: JSON.stringify(body)
};
}
}
27 changes: 24 additions & 3 deletions apps/backend/lambdas/expenditures/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ paths:
properties:
ok:
type: boolean

/expenditures:
get:
summary: GET /expenditures — paginated list
Expand Down Expand Up @@ -101,7 +100,29 @@ paths:
responses:
'201':
description: Success

/expenditures/{id}:
get:
summary: GET /expenditures/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK
delete:
summary: DELETE /expenditures/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK
/expenditures/{id}/status:
patch:
summary: PATCH /expenditures/{id}/status
Expand Down Expand Up @@ -134,4 +155,4 @@ paths:
'403':
description: Admin access required
'404':
description: Expenditure not found
description: Expenditure not found
187 changes: 187 additions & 0 deletions apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,48 @@ const staffUser = {
},
};

// Builds a Lambda event for GET or DELETE /expenditures/{id}
function idRequestEvent(method: 'GET' | 'DELETE', id: string | number) {
return {
rawPath: `/expenditures/${id}`,
requestContext: { http: { method } },
headers: { Authorization: 'Bearer fake-token' },
};
}

// Looks up a real expenditure_id belonging to the given project from the
// freshly-seeded DB, so tests don't hardcode ids that could drift if the
// seed data changes. Throws error if none found
async function firstExpenditureId(projectId: number): Promise<number> {
const client = await pool.connect();
try {
const result = await client.query(
'SELECT expenditure_id FROM branch.expenditures WHERE project_id = $1 ORDER BY expenditure_id LIMIT 1',
[projectId]
);
if (result.rows.length === 0) {
throw new Error(`No seeded expenditure found for project_id=${projectId}`);
}
return result.rows[0].expenditure_id;
} finally {
client.release();
}
}

// Directly queries the DB to confirm whether a row still exists
async function expenditureExists(id: number): Promise<boolean> {
const client = await pool.connect();
try {
const result = await client.query(
'SELECT 1 FROM branch.expenditures WHERE expenditure_id = $1',
[id]
);
return (result.rowCount ?? 0) > 0;
} finally {
client.release();
}
}

describe('Expenditures integration tests', () => {
beforeEach(async () => {
jest.clearAllMocks();
Expand Down Expand Up @@ -427,6 +469,151 @@ describe('Expenditures integration tests', () => {
});
});


describe('GET /expenditures/{id}', () => {
test('401: unauthenticated request is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false });
const id = await firstExpenditureId(1);

const res = await handler(idRequestEvent('GET', id));
expect(res.statusCode).toBe(401);
});

test('400: non-numeric id returns 400', async () => {
const res = await handler(idRequestEvent('GET', 'not-a-number'));
expect(res.statusCode).toBe(400);
});

test('404: unknown id returns 404', async () => {
const res = await handler(idRequestEvent('GET', 999999));
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body).message).toBe('Expenditure not found');
});

test('200: admin can fetch an expenditure by id', async () => {
const id = await firstExpenditureId(1);
const res = await handler(idRequestEvent('GET', id));

expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.body.expenditureId).toBe(id);
expect(body.body.projectId).toBe(1);
});

test('403: staff with no membership on the project cannot read it', async () => {
mockAuthenticateRequest.mockResolvedValue(staffUser);
const id = await firstExpenditureId(1); // staffUser has no role on project 1

const res = await handler(idRequestEvent('GET', id));
expect(res.statusCode).toBe(403);
});

test('200: staff can read an expenditure on a project they have a role on', async () => {
mockAuthenticateRequest.mockResolvedValue(staffUser);
const id = await firstExpenditureId(2); // staffUser is Staff on project 2

const res = await handler(idRequestEvent('GET', id));
expect(res.statusCode).toBe(200);
});
});

describe('DELETE /expenditures/{id}', () => {
test('401: unauthenticated request is rejected', async () => {
mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false });
const id = await firstExpenditureId(1);

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(401);
expect(await expenditureExists(id)).toBe(true);
});

test('400: non-numeric id returns 400', async () => {
const res = await handler(idRequestEvent('DELETE', 'abc'));
expect(res.statusCode).toBe(400);
});

test('404: unknown id returns 404', async () => {
const res = await handler(idRequestEvent('DELETE', 999999));
expect(res.statusCode).toBe(404);
});

test('403: Staff cannot delete an expenditure on a project they have no role on', async () => {
mockAuthenticateRequest.mockResolvedValue(staffUser);
const id = await firstExpenditureId(1); // staffUser has no membership on project 1

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(403);
expect(await expenditureExists(id)).toBe(true);
});

test('403: Staff role on their own project is still not sufficient to delete', async () => {
mockAuthenticateRequest.mockResolvedValue(staffUser);
const id = await firstExpenditureId(2); // staffUser is Staff on project 2

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(403);
expect(await expenditureExists(id)).toBe(true);
});

test('200: PI can delete an expenditure on their own project', async () => {
mockAuthenticateRequest.mockResolvedValue(piUser);
const id = await firstExpenditureId(1);

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(200);
expect(await expenditureExists(id)).toBe(false);
});

test('200: Accountant can delete an expenditure on their own project', async () => {
mockAuthenticateRequest.mockResolvedValue(accountantUser);
const id = await firstExpenditureId(1);

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(200);
expect(await expenditureExists(id)).toBe(false);
});

test('200: admin can delete an expenditure on any project', async () => {
const id = await firstExpenditureId(2);

const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(200);
expect(await expenditureExists(id)).toBe(false);
});

test('404: deleting the same expenditure twice returns 404 the second time', async () => {
const id = await firstExpenditureId(1);

const first = await handler(idRequestEvent('DELETE', id));
expect(first.statusCode).toBe(200);

const second = await handler(idRequestEvent('DELETE', id));
expect(second.statusCode).toBe(404);
});

test('deleting one expenditure does not affect other rows', async () => {
const client = await pool.connect();
let totalBefore: number;
try {
const result = await client.query('SELECT COUNT(*)::int AS count FROM branch.expenditures');
totalBefore = result.rows[0].count;
} finally {
client.release();
}

const id = await firstExpenditureId(1);
const res = await handler(idRequestEvent('DELETE', id));
expect(res.statusCode).toBe(200);

const client2 = await pool.connect();
try {
const result = await client2.query('SELECT COUNT(*)::int AS count FROM branch.expenditures');
expect(result.rows[0].count).toBe(totalBefore - 1);
} finally {
client2.release();
}
});
});
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]);
Expand Down
Loading
Loading