diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts index a9d9e35..dbb583c 100644 --- a/apps/api/src/modules/auth/auth.controller.ts +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -7,6 +7,24 @@ import { Role } from './auth.types'; import { parseCookies, setRefreshCookie, COOKIE_NAME } from './auth.session'; import { requestOtp, verifyOtpAndCreateSession } from './otp.service'; +const isDevSessionEnabled = (req: Request) => { + if (process.env.NODE_ENV === 'production') { + return false; + } + + if (process.env.ALLOW_DEV_SESSION !== 'true') { + return false; + } + + const expectedSecret = process.env.DEV_SESSION_SECRET?.trim(); + if (!expectedSecret) { + return false; + } + + const presentedSecret = req.header('x-dev-session-secret')?.trim(); + return presentedSecret === expectedSecret; +}; + export const refreshSession = async ( req: Request, res: Response, @@ -110,7 +128,7 @@ export const issueDevSession = async ( next: NextFunction, ) => { try { - if (process.env.NODE_ENV === 'production') { + if (!isDevSessionEnabled(req)) { return next(new AppError('Not found', 404, 'NOT_FOUND')); } diff --git a/apps/api/src/modules/auth/dev-session.integration.test.ts b/apps/api/src/modules/auth/dev-session.integration.test.ts new file mode 100644 index 0000000..5528aa4 --- /dev/null +++ b/apps/api/src/modules/auth/dev-session.integration.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import express from 'express'; +import request from 'supertest'; +import authRoutes from './auth.routes'; +import { errorHandler, notFoundHandler } from '../../core/errors/error-handler'; + +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use('/api/auth', authRoutes); + app.use(notFoundHandler); + app.use(errorHandler); + return app; +}; + +test('dev session endpoint returns 404 when local bootstrap is not enabled', async () => { + const previousAllow = process.env.ALLOW_DEV_SESSION; + const previousSecret = process.env.DEV_SESSION_SECRET; + const previousNodeEnv = process.env.NODE_ENV; + + try { + delete process.env.ALLOW_DEV_SESSION; + delete process.env.DEV_SESSION_SECRET; + process.env.NODE_ENV = 'development'; + + const response = await request(buildApp()).post('/api/auth/dev/session').send({ + userId: 'user-1', + role: 'CITIZEN', + deviceId: 'device-1', + clientType: 'mobile', + }); + + assert.equal(response.status, 404); + } finally { + process.env.ALLOW_DEV_SESSION = previousAllow; + process.env.DEV_SESSION_SECRET = previousSecret; + process.env.NODE_ENV = previousNodeEnv; + } +}); diff --git a/apps/api/src/modules/reports/reports.controller.ts b/apps/api/src/modules/reports/reports.controller.ts index d6f787a..fbc5dd0 100644 --- a/apps/api/src/modules/reports/reports.controller.ts +++ b/apps/api/src/modules/reports/reports.controller.ts @@ -5,6 +5,7 @@ import { AppError } from '../../core/errors/app-error'; import { logger } from '../../core/logging/logger'; import { MediaDraftModel } from '../media/media-draft.model'; import { MediaUploadModel } from '../media/media-upload.model'; +import { StatusUpdateModel } from './status-update.model'; import { ReportModel } from './report.model'; import { enqueueStellarAnchor } from './reports.anchor.queue'; import { buildDeterministicSnapshot, hashSnapshot } from './reports.snapshot'; diff --git a/apps/api/src/modules/reports/reports.detail.integration.test.ts b/apps/api/src/modules/reports/reports.detail.integration.test.ts new file mode 100644 index 0000000..bfaf80b --- /dev/null +++ b/apps/api/src/modules/reports/reports.detail.integration.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import reportsRoutes from './reports.routes'; +import { ReportModel } from './report.model'; +import { StatusUpdateModel } from './status-update.model'; +import { MediaUploadModel } from '../media/media-upload.model'; +import { errorHandler, notFoundHandler } from '../../core/errors/error-handler'; + +process.env.JWT_SECRET = 'test-secret'; + +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use('/api/reports', reportsRoutes); + app.use(notFoundHandler); + app.use(errorHandler); + return app; +}; + +test('GET /api/reports/:reportId returns report detail payload', async () => { + const originalFindById = ReportModel.findById; + const originalStatusFind = StatusUpdateModel.find; + const originalMediaFind = MediaUploadModel.find; + + try { + ReportModel.findById = ((reportId: string) => ({ + lean: async () => + reportId === '507f1f77bcf86cd799439011' + ? { + _id: '507f1f77bcf86cd799439011', + title: 'Broken drainage', + description: 'Water is not clearing after rain.', + category: 'DRAINAGE', + status: 'PENDING', + location: { type: 'Point', coordinates: [3.35, 6.6] }, + media_urls: ['https://media.example/report-1.jpg'], + anchor_status: 'ANCHOR_QUEUED', + anchor_attempts: 1, + anchor_last_error: null, + anchor_needs_attention: false, + anchor_failed_at: null, + stellar_tx_hash: null, + snapshot_hash: 'snapshot-hash', + data_hash: 'content-hash', + exif_verified: true, + exif_distance_meters: 12, + integrity_flag: 'NORMAL', + createdAt: new Date('2026-03-25T10:00:00.000Z'), + updatedAt: new Date('2026-03-25T10:05:00.000Z'), + } + : null, + })) as unknown as typeof ReportModel.findById; + + StatusUpdateModel.find = (() => ({ + sort: () => ({ + lean: async () => [ + { + _id: '507f191e810c19729de860ea', + previousStatus: 'PENDING', + nextStatus: 'ACKNOWLEDGED', + note: 'Agency has received the report', + actorId: '507f191e810c19729de860ff', + createdAt: new Date('2026-03-25T10:10:00.000Z'), + updatedAt: new Date('2026-03-25T10:10:00.000Z'), + }, + ], + }), + })) as unknown as typeof StatusUpdateModel.find; + + MediaUploadModel.find = (() => ({ + select: () => ({ + lean: async () => [ + { + _id: '507f191e810c19729de860ab', + url: 'https://media.example/report-1.jpg', + optimized_url: 'https://media.example/report-1.webp', + processing_status: 'DONE', + exif_verified: true, + }, + ], + }), + })) as unknown as typeof MediaUploadModel.find; + + const token = jwt.sign( + { sub: 'user-1', role: 'CITIZEN', tokenType: 'access' }, + process.env.JWT_SECRET!, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + + const response = await request(buildApp()) + .get('/api/reports/507f1f77bcf86cd799439011') + .set('Authorization', `Bearer ${token}`); + + assert.equal(response.status, 200); + assert.equal(response.body.data.id, '507f1f77bcf86cd799439011'); + assert.equal(response.body.data.anchor.status, 'ANCHOR_QUEUED'); + assert.equal(response.body.data.media[0].url, 'https://media.example/report-1.webp'); + assert.equal(response.body.data.history[0].nextStatus, 'ACKNOWLEDGED'); + } finally { + ReportModel.findById = originalFindById; + StatusUpdateModel.find = originalStatusFind; + MediaUploadModel.find = originalMediaFind; + } +}); + +test('GET /api/reports/:reportId returns 404 when report is missing', async () => { + const originalFindById = ReportModel.findById; + + try { + ReportModel.findById = (() => ({ + lean: async () => null, + })) as unknown as typeof ReportModel.findById; + + const token = jwt.sign( + { sub: 'user-1', role: 'CITIZEN', tokenType: 'access' }, + process.env.JWT_SECRET!, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + + const response = await request(buildApp()) + .get('/api/reports/507f1f77bcf86cd799439011') + .set('Authorization', `Bearer ${token}`); + + assert.equal(response.status, 404); + assert.equal(response.body.error.code, 'REPORT_NOT_FOUND'); + } finally { + ReportModel.findById = originalFindById; + } +}); diff --git a/apps/api/src/modules/reports/reports.routes.ts b/apps/api/src/modules/reports/reports.routes.ts index a39bfb4..56e212e 100644 --- a/apps/api/src/modules/reports/reports.routes.ts +++ b/apps/api/src/modules/reports/reports.routes.ts @@ -65,6 +65,14 @@ router.post( createReport, ); +router.get( + '/:reportId', + authenticateToken, + requireRole(['CITIZEN', 'AGENCY_ADMIN']), + validateRequest({ params: reportParamsSchema }), + getReportById, +); + router.post( '/verify', authenticateToken, diff --git a/apps/api/src/modules/reports/reports.schemas.ts b/apps/api/src/modules/reports/reports.schemas.ts index e7323a1..d2e456d 100644 --- a/apps/api/src/modules/reports/reports.schemas.ts +++ b/apps/api/src/modules/reports/reports.schemas.ts @@ -132,6 +132,10 @@ const boundsQuerySchema = z path: ['minLng'], }); +export const reportParamsSchema = z.object({ + reportId: z.string().trim().regex(/^[a-fA-F0-9]{24}$/, 'reportId must be a valid ObjectId'), +}); + export const reportsMapQuerySchema = z.union([radiusQuerySchema, boundsQuerySchema]); const optionalTrimmed = () =>