From 15ba2407cafbab21bbb6b401f35b65d82e573ad8 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Mon, 3 Aug 2026 20:23:20 -0400 Subject: [PATCH 1/2] feat(expenditures): presigned S3 uploads for receipts The Add Expense form requires a PDF receipt, but there was nowhere to put one: the expenditures lambda had no upload route, so FileUpload animated a fake progress bar over a setInterval and the file was dropped on submit. POST /expenditures has always accepted a receiptUrl; nothing could produce one. GET /expenditures/upload-url mints a presigned PUT for a PDF and returns the objectUrl to pass back as receiptUrl, mirroring the reports lambda. It is gated on the same project roles as creating an expenditure, and sanitises the browser-supplied filename so it cannot escape the project's key prefix. The route is registered above GET /expenditures/{id}, whose /^\/[^\/]+$/ matcher would otherwise read "upload-url" as an id. Infra: a receipts bucket (private, unlike the public-read reports bucket, with a CORS rule for the browser's direct PUT), the RECEIPTS_BUCKET_NAME env var, and s3:PutObject on the lambda role -- a presigned PUT carries the signer's permissions, so without it the browser's upload would 403 at S3. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/lambdas/expenditures/README.md | 1 + apps/backend/lambdas/expenditures/handler.ts | 90 ++++ .../backend/lambdas/expenditures/openapi.yaml | 43 ++ .../lambdas/expenditures/package-lock.json | 419 +++++++++++++++++- .../backend/lambdas/expenditures/package.json | 2 + .../test/receipt-upload.unit.test.ts | 157 +++++++ infrastructure/aws/lambda.tf | 21 + infrastructure/aws/s3.tf | 35 ++ 8 files changed, 767 insertions(+), 1 deletion(-) create mode 100644 apps/backend/lambdas/expenditures/test/receipt-upload.unit.test.ts diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 701dbe14..ae2da953 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -10,6 +10,7 @@ Lambda for tracking project expenditures. |--------|------|-------------| | GET | /health | Health check | | GET | /expenditures | | +| GET | /expenditures/upload-url | Presigned S3 PUT for a PDF receipt; returns the `objectUrl` to pass back as `receiptUrl` | | POST | /expenditures | | | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index 10e993e6..9f2c6cc2 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,8 +1,27 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import db from './db'; import { ExpenditureValidationUtils } from './validation-utils'; import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; +const REGION = process.env.AWS_REGION ?? 'us-east-2'; +const s3 = new S3Client({ region: REGION }); +const RECEIPTS_BUCKET = process.env.RECEIPTS_BUCKET_NAME ?? ''; + +/** Roles that may record spending on a project, and so attach a receipt to it. */ +const SPENDING_ROLES = ['PI', 'Accountant', 'Admin']; + +/** + * Strips everything but the basename and the characters safe in an S3 key. The + * name arrives from the browser, so without this a `fileName` of `../../x.pdf` + * would write outside the project's prefix. + */ +function safeFileName(fileName: string): string { + const base = fileName.split(/[\\/]/).pop() ?? ''; + return base.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120); +} + function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { const authCheck = checkAuthorization(authContext, level, resourceUserId); if (!authCheck.allowed) { @@ -98,6 +117,77 @@ export const handler = async (event: any): Promise => { return json(200, { data: expenditures }); } + // GET /expenditures/upload-url + // Must stay above GET /expenditures/{id}: that route's matcher is + // /^\/[^\/]+$/, which would otherwise swallow /upload-url as an id. + if ((normalizedPath === '/expenditures/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + if (!RECEIPTS_BUCKET) { + console.error('RECEIPTS_BUCKET_NAME is not set'); + return json(500, { message: 'Receipt uploads are not configured' }); + } + + const queryParams = event.queryStringParameters || {}; + const fileName = queryParams.fileName as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + // The receipt dropzone accepts application/pdf only. + if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { + return json(400, { message: 'Only PDF receipts are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + const project = await db + .selectFrom('branch.projects') + .where('project_id', '=', projectId) + .select('project_id') + .executeTakeFirst(); + if (!project) return json(404, { message: 'Project not found' }); + + // Same bar as POST /expenditures below: a receipt is only ever useful + // attached to an expenditure the caller is allowed to create. + if (!user.isAdmin) { + const membership = await db + .selectFrom('branch.project_memberships') + .where('project_id', '=', projectId) + .where('user_id', '=', user.userId!) + .select('role') + .executeTakeFirst(); + + if (!membership || !SPENDING_ROLES.includes(membership.role)) { + return json(403, { message: 'Unable to upload receipts for this project' }); + } + } + + // Date.now() keeps two uploads of the same filename from overwriting. + const key = `receipts/${projectId}/${Date.now()}-${safeFileName(fileName)}`; + const uploadUrl = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: RECEIPTS_BUCKET, + Key: key, + ContentType: 'application/pdf', + }), + { expiresIn: 3600 }, + ); + + return json(200, { + uploadUrl, + objectUrl: `https://${RECEIPTS_BUCKET}.s3.${REGION}.amazonaws.com/${key}`, + }); + } + // POST /expenditures if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { // Authenticate the request diff --git a/apps/backend/lambdas/expenditures/openapi.yaml b/apps/backend/lambdas/expenditures/openapi.yaml index 751d2f54..3be127d2 100644 --- a/apps/backend/lambdas/expenditures/openapi.yaml +++ b/apps/backend/lambdas/expenditures/openapi.yaml @@ -100,6 +100,49 @@ paths: responses: '201': description: Success + + /expenditures/upload-url: + get: + summary: GET /expenditures/upload-url + description: >- + Mints a presigned S3 PUT for a PDF receipt. The browser uploads to + `uploadUrl` directly, then passes `objectUrl` back as `receiptUrl` on + POST /expenditures. Requires the same project role as creating an + expenditure. + parameters: + - in: query + name: fileName + required: true + schema: + type: string + description: Must end in .pdf. + - in: query + name: projectId + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + uploadUrl: + type: string + description: Presigned PUT, valid for one hour. + objectUrl: + type: string + '400': + description: fileName missing or not a PDF, or projectId invalid + '401': + description: Unauthorized + '403': + description: Caller may not record spending on this project + '404': + description: Project not found + /expenditures/{id}: get: summary: GET /expenditures/{id} diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index 3f0a570a..0b72d507 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -8,6 +8,8 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", @@ -37,7 +39,11 @@ "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, @@ -46,6 +52,331 @@ "version": "1.0.0", "dev": true }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.25.tgz", + "integrity": "sha512-zUjEceMw6vhAxMayAlF/vkkKqP9gHbENqvz11t4FbfDlxB/WtW/Az1orJAQ00Pc/yORLQJXKE24w11Ktu/XBcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1102.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1102.0.tgz", + "integrity": "sha512-VQL/oWlt0+Rj2QZcAnp3+hMHW/T01EmkPQXf9ise2gz6d95U82eVE1dPBpMlnv4aDiz99TrMR0DHmceCjCkCmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.25", + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/credential-provider-node": "^3.972.77", + "@aws-sdk/middleware-sdk-s3": "^3.972.71", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.5.tgz", + "integrity": "sha512-O5otOc1c6UZh5HsHAaPdYBcUUR9HL6mtnKqvc8nxN/CKDGUBUpsdh0q8K04Uz/dd1i0TaGyIQuQNqoO7+ad2TQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.66.tgz", + "integrity": "sha512-bOzP2+zdJ0XrghywB4FaJXtGZCx9yS0AGps+VJ5yEgg30wVyHNmVDBwVDXcRypzQY5iLGCS3NSn0nsuISqjFCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.68.tgz", + "integrity": "sha512-lkunS8X+H6V76WE+t/uGQm/U8v0JXK5mLfNFTUAMlE1kqaCjwlmqKJrgCVtqjK/vqnlrSWsLK4Lr4NANBWlfTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.11.tgz", + "integrity": "sha512-KoDEolYtLHG/8C+IiZpXbJWyBOMkrHV+j66Kb9PBXmLv5euGb7aELvuCmLenoGAV6gBW2wM7TsG/1e5iulH4kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/credential-provider-env": "^3.972.66", + "@aws-sdk/credential-provider-http": "^3.972.68", + "@aws-sdk/credential-provider-login": "^3.972.73", + "@aws-sdk/credential-provider-process": "^3.972.66", + "@aws-sdk/credential-provider-sso": "^3.973.10", + "@aws-sdk/credential-provider-web-identity": "^3.972.72", + "@aws-sdk/nested-clients": "^3.997.40", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.73.tgz", + "integrity": "sha512-tjsxMkTAFkmiV9ycmymapb9nLECWVOwFs0bZMQ9gB9bnbY8/HwfukHZlWbXZZp7qkPU6EXAfOcMm3DioFFEywA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/nested-clients": "^3.997.40", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.77.tgz", + "integrity": "sha512-l4nitYCN/Ls57vtUfdextCjTjW41JD7lQiAnuR0RTbdByFc/6OmEAzwGd+lrp6CUtiXGQL1FCaYiamfHASrwBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.66", + "@aws-sdk/credential-provider-http": "^3.972.68", + "@aws-sdk/credential-provider-ini": "^3.973.11", + "@aws-sdk/credential-provider-process": "^3.972.66", + "@aws-sdk/credential-provider-sso": "^3.973.10", + "@aws-sdk/credential-provider-web-identity": "^3.972.72", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.66.tgz", + "integrity": "sha512-YOnX6bIhdjx0QfaENu2PB0eFm5MEc9ft8XNGQ+NxMfeLSq9aE+XjWCwDupEnV4UWv5ZFpBLJbTREIx7KNOoqpQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.10.tgz", + "integrity": "sha512-IsXnQ35j5VE+3ZK6aIhT5ypB+Jim3zRwVz0nYuVwyBKZyu/SYx+O2/LQpng8c2EiuwyqceabsDlYrICHDlJPsA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/nested-clients": "^3.997.40", + "@aws-sdk/token-providers": "3.1102.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.72.tgz", + "integrity": "sha512-nj9Zlsy7ya+fy+jhWTJwgfr7YdtDM4xHyZvgKuftuny0UgROVx9lxwvsWJSLvpKk4lig0m0tHng3k1fEnt0LeA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/nested-clients": "^3.997.40", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.71.tgz", + "integrity": "sha512-5fpExT7JOIZSIWExXCgJVbZzbaOlNrS/rE5Eoesnp0ONMbnCtiyYsCkahNIdlXtKrO6XHqXI6ceqssVWMYKmWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.40.tgz", + "integrity": "sha512-hEdHT0PBR4fkGxWhwKG5EtEYKnAM7HKkp0vD10ufk4YcXejH4r4q6G/XhPzjUc6Yxo5kBS2vHg7llj4ViR9VTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1102.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1102.0.tgz", + "integrity": "sha512-7ZRat6FPn1+disqJ0EGXF2d5aJm0/uSufgWBcfUcQ7rBbu9JZIWWRYWESvrpmzyYnhYIgqEEuzIRHeJ39YM1lQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1102.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1102.0.tgz", + "integrity": "sha512-Ua700vVvM1q105yABSUQWkCK6FeTrNfU6ORGetJe5BzkZWY7QhkF7SVTOlmDGWRDNd6jbyY0Dv5e+E4bMBEmLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.5", + "@aws-sdk/nested-clients": "^3.997.40", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1629,6 +1960,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2431,6 +2843,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -5981,7 +6399,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-detect": { diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 0d101d06..e9f9896f 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -26,6 +26,8 @@ "typescript": "^5.4.5" }, "dependencies": { + "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", diff --git a/apps/backend/lambdas/expenditures/test/receipt-upload.unit.test.ts b/apps/backend/lambdas/expenditures/test/receipt-upload.unit.test.ts new file mode 100644 index 00000000..634f1741 --- /dev/null +++ b/apps/backend/lambdas/expenditures/test/receipt-upload.unit.test.ts @@ -0,0 +1,157 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; + +// The handler reads RECEIPTS_BUCKET_NAME at module load, so it has to be set +// before the require below -- an `import` would be hoisted above this line. +process.env.RECEIPTS_BUCKET_NAME = 'test-receipts-bucket'; +process.env.AWS_REGION = 'us-east-2'; + +jest.mock('../db'); +jest.mock('../auth'); +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/put'), +})); + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { handler } = require('../handler') as typeof import('../handler'); +const db = require('../db').default as any; +const { authenticateRequest } = require('../auth') as typeof import('../auth'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner') as { + getSignedUrl: jest.Mock<(client: unknown, command: unknown) => Promise>; +}; +/* eslint-enable @typescript-eslint/no-var-requires */ + +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; + +const admin = { + isAuthenticated: true, + user: { cognitoSub: 'admin-sub', userId: 1, email: 'ashley@branch.org', isAdmin: true }, +}; +const staff = { + isAuthenticated: true, + user: { cognitoSub: 'staff-sub', userId: 3, email: 'nour@branch.org', isAdmin: false }, +}; + +function uploadUrlEvent(query: Record = { fileName: 'receipt.pdf', projectId: '1' }) { + return { + rawPath: '/expenditures/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: query, + } as any; +} + +/** A kysely builder stub whose terminal call resolves to `value`. */ +function chain(value: any) { + const p: any = {}; + for (const m of ['select', 'selectAll', 'where']) p[m] = jest.fn().mockReturnValue(p); + p.executeTakeFirst = jest.fn<() => Promise>().mockResolvedValue(value); + return p; +} + +/** Project 1 exists; the caller holds `role` on it (undefined = no membership). */ +function seed(role?: string) { + db.selectFrom = jest.fn(); + db.selectFrom.mockReturnValueOnce(chain({ project_id: 1 })); + db.selectFrom.mockReturnValueOnce(chain(role ? { role } : undefined)); +} + +beforeEach(() => { + jest.clearAllMocks(); + getSignedUrl.mockResolvedValue('https://signed.example/put'); + mockAuthenticateRequest.mockResolvedValue(admin as any); + seed(); +}); + +describe('GET /expenditures/upload-url', () => { + test('200: returns a presigned PUT and the object URL to store as receiptUrl', async () => { + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); + expect(body.uploadUrl).toBe('https://signed.example/put'); + expect(body.objectUrl).toMatch( + /^https:\/\/test-receipts-bucket\.s3\.us-east-2\.amazonaws\.com\/receipts\/1\/\d+-receipt\.pdf$/, + ); + + const command = getSignedUrl.mock.calls[0][1] as any; + expect(command.input.Bucket).toBe('test-receipts-bucket'); + expect(command.input.ContentType).toBe('application/pdf'); + }); + + test('200: a project member with a spending role is allowed', async () => { + mockAuthenticateRequest.mockResolvedValue(staff as any); + seed('Accountant'); + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(200); + }); + + test('403: a member without a spending role is refused', async () => { + mockAuthenticateRequest.mockResolvedValue(staff as any); + seed('Staff'); + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(403); + expect(getSignedUrl).not.toHaveBeenCalled(); + }); + + test('403: a non-member is refused', async () => { + mockAuthenticateRequest.mockResolvedValue(staff as any); + seed(); + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(403); + }); + + test('401: unauthenticated', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false } as any); + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(401); + }); + + test('404: unknown project', async () => { + db.selectFrom = jest.fn().mockReturnValueOnce(chain(undefined)); + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(404); + }); + + test('400: rejects a non-PDF, and anything but a positive integer projectId', async () => { + expect((await handler(uploadUrlEvent({ fileName: 'receipt.exe', projectId: '1' }))).statusCode).toBe(400); + seed(); + expect((await handler(uploadUrlEvent({ projectId: '1' } as any))).statusCode).toBe(400); + seed(); + expect((await handler(uploadUrlEvent({ fileName: 'r.pdf', projectId: '0' }))).statusCode).toBe(400); + seed(); + expect((await handler(uploadUrlEvent({ fileName: 'r.pdf', projectId: 'abc' }))).statusCode).toBe(400); + expect(getSignedUrl).not.toHaveBeenCalled(); + }); + + test('keeps a traversing filename inside the project prefix', async () => { + const res = await handler(uploadUrlEvent({ fileName: '../../etc/passwd.pdf', projectId: '1' })); + expect(res.statusCode).toBe(200); + const { objectUrl } = JSON.parse(res.body); + expect(objectUrl).toContain('/receipts/1/'); + expect(objectUrl).not.toContain('..'); + expect(objectUrl).toMatch(/\d+-passwd\.pdf$/); + }); + + test('is matched before GET /expenditures/{id}, which would read it as an id', async () => { + const res = await handler(uploadUrlEvent()); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).message).toBeUndefined(); + }); + + test('500: unconfigured bucket fails loudly rather than signing against ""', async () => { + // Re-require the handler with the env var absent -- it is captured once, at + // module load. isolateModules' callback is synchronous, so the request is + // awaited outside it rather than returned from it. + let unconfigured!: typeof import('../handler'); + delete process.env.RECEIPTS_BUCKET_NAME; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + unconfigured = require('../handler'); + }); + process.env.RECEIPTS_BUCKET_NAME = 'test-receipts-bucket'; + + const res = await unconfigured.handler(uploadUrlEvent()); + expect(res.statusCode).toBe(500); + expect(JSON.parse(res.body).message).toBe('Receipt uploads are not configured'); + }); +}); diff --git a/infrastructure/aws/lambda.tf b/infrastructure/aws/lambda.tf index 8bddf3d3..64110ba3 100644 --- a/infrastructure/aws/lambda.tf +++ b/infrastructure/aws/lambda.tf @@ -46,6 +46,24 @@ resource "aws_iam_role_policy" "lambda_cognito_admin" { }) } +# A presigned PUT carries the signer's permissions, so the role that mints the +# URL in GET /expenditures/upload-url must itself be allowed to write the object +# -- otherwise the browser's upload 403s at S3. +resource "aws_iam_role_policy" "lambda_receipts_bucket" { + name = "branch-lambda-receipts-bucket" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "ExpenditureReceiptWrite" + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "${aws_s3_bucket.receipts_bucket.arn}/*" + }] + }) +} + # Get AWS account ID for unique bucket naming data "aws_caller_identity" "current" {} @@ -152,6 +170,9 @@ resource "aws_lambda_function" "functions" { # on branch-reports only; listed here so this authoritative block does not # wipe it. Harmless on the other five functions. REPORTS_BUCKET_NAME = aws_s3_bucket.reports_bucket.id + + # Read by lambdas/expenditures/handler.ts for GET /expenditures/upload-url. + RECEIPTS_BUCKET_NAME = aws_s3_bucket.receipts_bucket.id } } } \ No newline at end of file diff --git a/infrastructure/aws/s3.tf b/infrastructure/aws/s3.tf index 4d43abe7..eb111a27 100644 --- a/infrastructure/aws/s3.tf +++ b/infrastructure/aws/s3.tf @@ -33,3 +33,38 @@ output "reports_bucket_name" { description = "Name of the S3 bucket for generated reports" value = aws_s3_bucket.reports_bucket.id } + +# Expenditure receipts. Separate from the reports bucket so the two lifecycles +# and access policies can diverge: a receipt is uploaded by the browser against +# a presigned PUT, a report is written by the lambda itself. +resource "aws_s3_bucket" "receipts_bucket" { + bucket_prefix = "c4c-branch-expenditure-receipts" +} + +resource "aws_s3_bucket_public_access_block" "receipts_bucket_public_access" { + bucket = aws_s3_bucket.receipts_bucket.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# The browser PUTs straight to S3, from the CloudFront origin, so S3 has to +# answer the preflight itself -- API Gateway is not in that request's path. +resource "aws_s3_bucket_cors_configuration" "receipts_bucket" { + bucket = aws_s3_bucket.receipts_bucket.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["PUT"] + allowed_origins = ["*"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} + +output "receipts_bucket_name" { + description = "Name of the S3 bucket for expenditure receipts" + value = aws_s3_bucket.receipts_bucket.id +} From b0147d4246e5f34b0b3022d1c8b95ba5c562ff9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Aug 2026 00:24:00 +0000 Subject: [PATCH 2/2] chore: auto-format terraform and update documentation - Auto-formatted .tf files with terraform fmt - Updated README.md with terraform-docs Co-authored-by: nourshoreibah --- infrastructure/aws/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 890a502f..66bca7b9 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -49,6 +49,7 @@ No modules. | [aws_iam_role_policy.ci_plan_state_lock](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.ci_preview](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy.lambda_cognito_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_receipts_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.ci_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.ci_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/iam_role_policy_attachment) | resource | @@ -56,10 +57,13 @@ No modules. | [aws_lambda_permission.api_gateway_permissions](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/lambda_permission) | resource | | [aws_s3_bucket.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket.receipts_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.reports_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_cors_configuration.receipts_bucket](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_cors_configuration) | resource | | [aws_s3_bucket_policy.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | | [aws_s3_bucket_policy.reports_bucket_policy](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_policy) | resource | | [aws_s3_bucket_public_access_block.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | +| [aws_s3_bucket_public_access_block.receipts_bucket_public_access](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_public_access_block.reports_bucket_public_access](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_server_side_encryption_configuration.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | | [aws_s3_bucket_versioning.lambda_deployments](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/s3_bucket_versioning) | resource | @@ -103,5 +107,6 @@ No modules. | [frontend\_bucket](#output\_frontend\_bucket) | S3 bucket the frontend build is synced to | | [frontend\_cloudfront\_distribution\_id](#output\_frontend\_cloudfront\_distribution\_id) | CloudFront distribution id (for cache invalidation in CI) | | [frontend\_cloudfront\_domain](#output\_frontend\_cloudfront\_domain) | Public URL of the frontend | +| [receipts\_bucket\_name](#output\_receipts\_bucket\_name) | Name of the S3 bucket for expenditure receipts | | [reports\_bucket\_name](#output\_reports\_bucket\_name) | Name of the S3 bucket for generated reports |