diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index 50746b8..321042f 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -13,6 +13,8 @@ TODO: Add a description of the reports lambda. | GET | /reports | | | GET | /reports/upload-url | | | POST | /reports | | +| GET | /reports/{id} | | +| DELETE | /reports/{id} | | ## Setup diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 7b01f65..0e87a55 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -263,7 +263,76 @@ export const handler = async (event: any): Promise => { return json(201, report); } - // <<< ROUTES-END + + // GET /reports/{id} + if ((/^\/reports\/\d+$/.test(normalizedPath) || /^\/\d+$/.test(normalizedPath)) && method === 'GET') { const id = normalizedPath.split('/').pop() as string; + + if (!/^\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 report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to this report' }); + } + + return json(200, { + ok: true, + route: 'GET /reports/{id}', + pathParams: { id }, + body: { + report_id: report.report_id, + project_id: report.project_id, + title: report.title, + object_url: report.object_url, + report_type: report.report_type, + date_created: report.date_created, + } + }); + } + + // DELETE /reports/{id} + if ((/^\/reports\/\d+$/.test(normalizedPath) || /^\/\d+$/.test(normalizedPath)) && method === 'DELETE') { + const id = normalizedPath.split('/').pop() as string; + + if (!/^\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 report = await db.selectFrom('branch.reports').where('report_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!report) return json(404, { message: 'Report not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, report.project_id, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to delete this report' }); + } + + const deleted = await db.deleteFrom('branch.reports').where('report_id', '=', Number(id)).execute(); + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'Report not found' }); + } + + return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id } }); + } + // <<< ROUTES-END return json(404, { message: 'Not Found', path: normalizedPath, method }); } catch (err) { diff --git a/apps/backend/lambdas/reports/openapi.yaml b/apps/backend/lambdas/reports/openapi.yaml index 5e64c4c..9d4a368 100644 --- a/apps/backend/lambdas/reports/openapi.yaml +++ b/apps/backend/lambdas/reports/openapi.yaml @@ -25,7 +25,7 @@ paths: /reports: get: - summary: GET /reports — paginated list + summary: GET /reports � paginated list parameters: - in: query name: page @@ -86,7 +86,7 @@ paths: '401': description: Unauthorized post: - summary: POST /reports — save a manually uploaded report + summary: POST /reports � save a manually uploaded report description: > Creates a report record using the S3 object URL obtained from GET /reports/upload-url after the client has uploaded the file directly @@ -214,6 +214,30 @@ paths: '500': description: Internal server error + /reports/{id}: + get: + summary: GET /reports/{id} + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: OK + delete: + summary: DELETE /reports/{id} + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: OK + components: securitySchemes: BearerAuth: diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index a338e5f..6cd0c4a 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.995.0", - "@aws-sdk/s3-request-presigner": "^3.1029.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", @@ -321,29 +321,33 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/core/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/@aws-sdk/crc64-nvme": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.6.tgz", @@ -756,17 +760,35 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1029.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1029.0.tgz", - "integrity": "sha512-YbHPaha4DYgJWdPorGV5ZSCCqHafGj4GiyqXmXFlCJSsqlOd3xEcemhOZGjrB9epdiVEUtB3DDJXGYYj55ITdQ==", + "version": "3.995.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.995.0.tgz", + "integrity": "sha512-+eJYJcB6XkViGbSAoWl7sQx3izt2y64Oy6SWec/HY2a6ibd5RtUJbEIkvoS2RUu7drm4yu2WyLgSNMQg/7nt0g==", + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.16", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-format-url": "^3.972.9", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@aws-sdk/signature-v4-multi-region": "3.995.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-format-url": "^3.972.3", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.995.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.995.0.tgz", + "integrity": "sha512-9Qx5JcAucnxnomREPb2D6L8K8GLG0rknt3+VK/BU3qTUynAcV4W21DQ04Z2RKDw+DYpW88lsZpXbVetWST2WUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.11", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -774,16 +796,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", - "integrity": "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==", + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.28", - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -809,12 +829,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "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.14.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -850,13 +870,12 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz", - "integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.35.tgz", + "integrity": "sha512-PIqDWSr2N3akz0K17KAo1Nusac2JpKG9scY8u2XUVH7UBg0G9AO9xOM4iSdn+4Ab7AETZr/tcVNfyfVR+ot/fw==", + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.975.3", "tslib": "^2.6.2" }, "engines": { @@ -913,13 +932,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", - "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", - "fast-xml-parser": "5.5.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2585,20 +2603,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2981,18 +2991,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -3018,9 +3023,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "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" @@ -4960,41 +4965,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6881,21 +6851,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz", - "integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7716,18 +7671,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 9951c00..f126265 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -13,13 +13,13 @@ "devDependencies": { "@branch/types": "file:../../../../shared/types", "@jest/globals": "^30.2.0", - "esbuild": "^0.25.12", - "jest": "^30.2.0", "@types/aws-lambda": "^8.10.131", "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pdfmake": "^0.3.1", "@types/pg": "^8.16.0", + "esbuild": "^0.25.12", + "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.6", @@ -27,14 +27,14 @@ "typescript": "^5.4.5" }, "dependencies": { - "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "@aws-sdk/client-s3": "^3.995.0", - "@aws-sdk/s3-request-presigner": "^3.1029.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", + "docx": "^9.5.0", "dotenv": "^16.4.7", "kysely": "^0.28.11", - "docx": "^9.5.0", "pdfmake": "^0.3.4", "pg": "^8.18.0" } diff --git a/apps/backend/lambdas/reports/test/reports.e2e.test.ts b/apps/backend/lambdas/reports/test/reports.e2e.test.ts index 56058e5..a4c1d3d 100644 --- a/apps/backend/lambdas/reports/test/reports.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/reports.e2e.test.ts @@ -333,4 +333,109 @@ describe('Reports e2e tests', () => { expect(JSON.parse(res.body).message).toBe('Invalid JSON in request body'); }); }); + + describe('GET /reports/{id}', () => { + function idEvent(method: 'GET' | 'DELETE', id: string | number) { + return { + rawPath: `/reports/${id}`, + requestContext: { http: { method } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + test('200: returns report by id', async () => { + // seed report 1 belongs to project 1 + const res = await handler(idEvent('GET', 1)); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.body.report_id).toBe(1); + expect(body.body.project_id).toBe(1); + }); + + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(idEvent('GET', 'abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: unknown id returns 404', async () => { + const res = await handler(idEvent('GET', 99999)); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Report not found'); + }); + + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(idEvent('GET', 1)); + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /reports/{id}', () => { + function idEvent(method: 'GET' | 'DELETE', id: string | number) { + return { + rawPath: `/reports/${id}`, + requestContext: { http: { method } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + test('200: admin can delete a report, removed from db', async () => { + const res = await handler(idEvent('DELETE', 4)); + expect(res.statusCode).toBe(200); + + const client = await pool.connect(); + try { + const result = await client.query('SELECT * FROM branch.reports WHERE report_id = 4'); + expect(result.rows.length).toBe(0); + } finally { + client.release(); + } + }); + + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(idEvent('DELETE', 'abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: unknown id returns 404', async () => { + const res = await handler(idEvent('DELETE', 99999)); + expect(res.statusCode).toBe(404); + }); + + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(idEvent('DELETE', 1)); + expect(res.statusCode).toBe(401); + }); + + test('404: deleting the same report twice returns 404 the second time', async () => { + const first = await handler(idEvent('DELETE', 5)); + expect(first.statusCode).toBe(200); + + const second = await handler(idEvent('DELETE', 5)); + expect(second.statusCode).toBe(404); + }); + + test('deleting one report 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.reports'); + totalBefore = result.rows[0].count; + } finally { + client.release(); + } + + const res = await handler(idEvent('DELETE', 3)); + expect(res.statusCode).toBe(200); + + const client2 = await pool.connect(); + try { + const result = await client2.query('SELECT COUNT(*)::int AS count FROM branch.reports'); + expect(result.rows[0].count).toBe(totalBefore - 1); + } finally { + client2.release(); + } + }); + }); }); diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index fdf7542..218c2ed 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -553,3 +553,204 @@ describe('POST /reports unit tests', () => { }); }); }); + +describe('GET /reports/{id} unit tests', () => { + function idEvent(method: 'GET' | 'DELETE', id: string) { + return { + rawPath: `/reports/${id}`, + requestContext: { http: { method } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + const fakeReport = { + report_id: 5, + project_id: 2, + title: 'Test Report', + object_url: 'https://s3.amazonaws.com/reports/test.pdf', + report_type: 'technical', + date_created: new Date('2025-01-01'), + }; + + function setupReportMock(report: Record | undefined) { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(report as any), + }), + }), + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + mockCheckProjectAccess.mockReturnValue(true as any); + setupReportMock(fakeReport); + }); + + describe('Validation', () => { + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(idEvent('GET', 'abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: negative id falls through to catch-all', async () => { + const res = await handler(idEvent('GET', '-5')); + expect(res.statusCode).toBe(404); + }); + + test('404: decimal id falls through to catch-all', async () => { + const res = await handler(idEvent('GET', '5.5')); + expect(res.statusCode).toBe(404); + }); + }); + + describe('Authentication', () => { + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(idEvent('GET', '5')); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Authentication required'); + }); + }); + + describe('Business logic', () => { + test('404: report does not exist', async () => { + setupReportMock(undefined); + const res = await handler(idEvent('GET', '999')); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Report not found'); + }); + + test('403: user has no project access', async () => { + mockCheckProjectAccess.mockReturnValue(false as any); + const res = await handler(idEvent('GET', '5')); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('You do not have access to this report'); + }); + + test('200: returns report for user with project access', async () => { + const res = await handler(idEvent('GET', '5')); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.ok).toBe(true); + expect(json.route).toBe('GET /reports/{id}'); + expect(json.body.report_id).toBe(5); + expect(json.body.project_id).toBe(2); + expect(json.body.title).toBe('Test Report'); + }); + + test('checkProjectAccess is called with the report\'s own project_id', async () => { + await handler(idEvent('GET', '5')); + expect(mockCheckProjectAccess).toHaveBeenCalledWith(1, 2, true); + }); + }); +}); + +describe('DELETE /reports/{id} unit tests', () => { + function idEvent(method: 'GET' | 'DELETE', id: string) { + return { + rawPath: `/reports/${id}`, + requestContext: { http: { method } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + const fakeReport = { + report_id: 5, + project_id: 2, + title: 'Test Report', + object_url: 'https://s3.amazonaws.com/reports/test.pdf', + report_type: 'technical', + date_created: new Date('2025-01-01'), + }; + + function setupReportMock(report: Record | undefined) { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(report as any), + }), + }), + }); + } + + function setupDeleteMock(numDeletedRows: bigint) { + mockDb.deleteFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + execute: jest.fn().mockReturnValue([{ numDeletedRows }]), + }), + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + mockCheckProjectAccess.mockReturnValue(true as any); + setupReportMock(fakeReport); + setupDeleteMock(1n); + }); + + describe('Validation', () => { + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(idEvent('DELETE', 'abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: negative id falls through to catch-all', async () => { + const res = await handler(idEvent('DELETE', '-1')); + expect(res.statusCode).toBe(404); + }); + }); + + describe('Authentication', () => { + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(idEvent('DELETE', '5')); + expect(res.statusCode).toBe(401); + }); + }); + + describe('Business logic', () => { + test('404: report does not exist (checked before authorization)', async () => { + setupReportMock(undefined); + const res = await handler(idEvent('DELETE', '999')); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Report not found'); + expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + }); + + test('403: user has no project access', async () => { + mockCheckProjectAccess.mockReturnValue(false as any); + const res = await handler(idEvent('DELETE', '5')); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('You do not have access to delete this report'); + expect(mockDb.deleteFrom).not.toHaveBeenCalled(); + }); + + test('404: row already gone by the time delete executes (race condition)', async () => { + setupDeleteMock(0n); + const res = await handler(idEvent('DELETE', '5')); + expect(res.statusCode).toBe(404); + }); + + test('200: deletes report for user with project access', async () => { + const res = await handler(idEvent('DELETE', '5')); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.ok).toBe(true); + expect(json.route).toBe('DELETE /reports/{id}'); + expect(json.pathParams).toEqual({ id: '5' }); + }); + + test('checkProjectAccess is called with the report\'s own project_id', async () => { + await handler(idEvent('DELETE', '5')); + expect(mockCheckProjectAccess).toHaveBeenCalledWith(1, 2, true); + }); + }); +});