diff --git a/backend/src/common/body-parsers.spec.ts b/backend/src/common/body-parsers.spec.ts new file mode 100644 index 0000000..ecde076 --- /dev/null +++ b/backend/src/common/body-parsers.spec.ts @@ -0,0 +1,87 @@ +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; +import request from 'supertest'; +import { createJsonBodyParser, VOICE_EXTRACT_PATH } from './body-parsers'; + +/** + * Guards a bug that made the voice endpoint completely unusable while surfacing as a + * generic 500: the large-body limit stopped applying, so every real recording — anything + * past roughly 20 seconds of audio — was rejected by Express's 100 kb default. + */ + +type ProbeBody = { keys?: number; type?: string }; + +function buildApp(): express.Express { + const app = express(); + app.use(createJsonBodyParser()); + app.post('*splat', (req: Request, res: Response) => { + res.json({ keys: Object.keys((req.body ?? {}) as object).length }); + }); + // Surface body-parser's own error instead of Express's HTML default page. + app.use( + ( + err: { status?: number; type?: string }, + _req: Request, + res: Response, + next: NextFunction, + ) => { + if (res.headersSent) { + next(err); + return; + } + res.status(err.status ?? 500).json({ type: err.type }); + }, + ); + return app; +} + +const bodyOfKb = (kb: number) => ({ audio: 'A'.repeat(kb * 1024) }); + +describe('createJsonBodyParser', () => { + it('accepts a body far past the default limit on the voice route', async () => { + const res = await request(buildApp()) + .post(VOICE_EXTRACT_PATH) + .send(bodyOfKb(300)); + expect(res.status).toBe(200); + expect((res.body as ProbeBody).keys).toBe(1); + }); + + it('accepts a realistic worst-case recording', async () => { + // Two minutes of opus is well under 1 MB, but wav is far larger; 4 MB must pass. + const res = await request(buildApp()) + .post(VOICE_EXTRACT_PATH) + .send(bodyOfKb(4096)); + expect(res.status).toBe(200); + }); + + it('keeps the default limit on every other route', async () => { + // The larger limit must not leak app-wide as a side effect. + const res = await request(buildApp()) + .post('/api/auth/login') + .send(bodyOfKb(300)); + expect(res.status).toBe(413); + expect((res.body as ProbeBody).type).toBe('entity.too.large'); + }); + + it('still parses ordinary bodies on ordinary routes', async () => { + const res = await request(buildApp()) + .post('/api/auth/login') + .send({ email: 'a@b.c' }); + expect(res.status).toBe(200); + expect((res.body as ProbeBody).keys).toBe(1); + }); + + it('does not widen the limit for a path that merely looks similar', async () => { + for (const path of [ + '/api/voice/extract/extra', + '/api/voice', + '/voice/extract', + ]) { + const res = await request(buildApp()).post(path).send(bodyOfKb(300)); + expect(res.status).toBe(413); + } + }); +}); diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts new file mode 100644 index 0000000..c845d91 --- /dev/null +++ b/backend/src/common/body-parsers.ts @@ -0,0 +1,35 @@ +import { + json, + type NextFunction, + type Request, + type RequestHandler, + type Response, +} from 'express'; + +/** The one route that accepts a large body, and how large. */ +export const VOICE_EXTRACT_PATH = '/api/voice/extract'; +export const VOICE_BODY_LIMIT = '10mb'; + +/** + * JSON body parsing for the whole app. + * + * Voice recordings are base64 JSON and pass Express's 100 kb default at roughly 20 seconds + * of audio, so that one route needs a larger limit while every other endpoint keeps the + * default — a large body should not become acceptable everywhere. + * + * Deliberately a single middleware that *chooses* a parser, rather than a path-mounted + * parser stacked in front of a default one. That arrangement relied on Express's + * mount-path stripping plus body-parser skipping an already-parsed request, and it + * silently stopped applying when the surrounding middleware order shifted — at which point + * the endpoint rejected every real recording with a 500. One explicit branch has no such + * coupling, and is covered by body-parsers.spec.ts. + */ +export function createJsonBodyParser(): RequestHandler { + const voiceParser = json({ limit: VOICE_BODY_LIMIT }); + const defaultParser = json(); + + return (req: Request, res: Response, next: NextFunction) => + req.path === VOICE_EXTRACT_PATH + ? voiceParser(req, res, next) + : defaultParser(req, res, next); +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7509b3a..0749bbd 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,7 +1,8 @@ // backend/src/main.ts import { NestFactory } from '@nestjs/core'; -import { json, urlencoded } from 'express'; +import { urlencoded } from 'express'; import { AppModule } from './app.module'; +import { createJsonBodyParser } from './common/body-parsers'; import { ValidationPipe } from '@nestjs/common'; import cookieParser from 'cookie-parser'; // 👈 Change this line! import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -29,12 +30,8 @@ async function bootstrap() { // reject a voice recording at its 100 kb default before any later middleware ran. const app = await NestFactory.create(AppModule, { bodyParser: false }); - // Voice recordings are base64 JSON and pass 100 kb at roughly 20 seconds of audio. - // Registered first and scoped to the one route: body-parser marks the request handled, - // so the default-limit parser below skips it and every other endpoint keeps the - // standard limit. - app.use('/api/voice/extract', json({ limit: '10mb' })); - app.use(json()); + // Voice needs a larger JSON limit than everything else; see body-parsers.ts. + app.use(createJsonBodyParser()); app.use(urlencoded({ extended: true })); app.useGlobalFilters(new HttpExceptionFilter());