From ff12da514af2a40f9f991ac1199abfc1f472a15c Mon Sep 17 00:00:00 2001 From: Jinho Ayden Jeong <144667387+ayden94@users.noreply.github.com> Date: Thu, 7 May 2026 21:47:07 +0900 Subject: [PATCH] Resolve #1662: Add runtime edge regression coverage --- packages/runtime/src/application.test.ts | 113 +++++++++++++++++- .../runtime/src/node/node-request.test.ts | 25 ++++ packages/runtime/src/web.test.ts | 32 +++++ 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/application.test.ts b/packages/runtime/src/application.test.ts index be5f939dd..6e5af5e41 100644 --- a/packages/runtime/src/application.test.ts +++ b/packages/runtime/src/application.test.ts @@ -772,6 +772,57 @@ describe('bootstrapApplication', () => { await app.close(); }); + it('uses maxBodySize as the Node adapter multipart total-size fallback', async () => { + @Controller('/uploads') + class UploadController { + @Post('/') + upload(_input: undefined, context: RequestContext) { + return { + body: context.request.body, + fileCount: context.request.files?.length ?? 0, + }; + } + } + + class AppModule {} + defineModule(AppModule, { + controllers: [UploadController], + }); + + const port = await findAvailablePort(); + const app = registerAppForCleanup(await bootstrapNodeApplication(AppModule, { + cors: false, + maxBodySize: 10, + port, + })); + + await app.listen(); + + const form = new FormData(); + form.set('name', 'Ada Lovelace'); + form.set('payload', new Blob(['hello'], { type: 'text/plain' }), 'payload.txt'); + + const response = await fetchForTest(`http://127.0.0.1:${String(port)}/uploads`, { + body: form, + headers: { 'x-request-id': 'req-multipart-fallback' }, + method: 'POST', + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + details: undefined, + message: 'Multipart body exceeds the maximum size of 10 bytes.', + meta: undefined, + requestId: 'req-multipart-fallback', + status: 413, + }, + }); + + await app.close(); + }); + it('serves text and HTML bodies over the Node adapter without JSON quoting', async () => { const docsHtml = 'Docs'; const metricsBody = 'process_cpu_seconds_total 1'; @@ -1284,7 +1335,6 @@ describe('bootstrapApplication', () => { }); const originalExitCode = process.exitCode; - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => undefined as never) as typeof process.exit); const port = await findAvailablePort(); const app = await runNodeApplication(AppModule, { cors: false, @@ -1301,7 +1351,6 @@ describe('bootstrapApplication', () => { process.emit('SIGTERM', 'SIGTERM'); await vi.advanceTimersByTimeAsync(26); - expect(exitSpy).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(loggerEvents).toContain( 'error:FluoFactory:Shutdown timeout exceeded after 25ms; leaving process termination to the host.:none', @@ -1309,12 +1358,70 @@ describe('bootstrapApplication', () => { } finally { app.close = originalClose; await app.close(); - exitSpy.mockRestore(); process.exitCode = originalExitCode; vi.useRealTimers(); } }); + it('marks signal-driven shutdown failures without terminating the host process directly', async () => { + const shutdownLogged = createDeferred(); + const shutdownError = new Error('close failed'); + const loggerEvents: string[] = []; + const logger: ApplicationLogger = { + debug() {}, + error(message, error, context) { + loggerEvents.push(`error:${context}:${message}:${error instanceof Error ? error.message : 'none'}`); + + if (message === 'Failed to shut down the application cleanly.') { + shutdownLogged.resolve(); + } + }, + log() {}, + warn() {}, + }; + + @Controller('/health') + class HealthController { + @Get('/') + getHealth() { + return { ok: true }; + } + } + + class AppModule {} + defineModule(AppModule, { + controllers: [HealthController], + }); + + const originalExitCode = process.exitCode; + const port = await findAvailablePort(); + const app = await runNodeApplication(AppModule, { + cors: false, + logger, + port, + shutdownSignals: ['SIGTERM'], + }); + const originalClose = app.close.bind(app); + let observedSignal: string | undefined; + app.close = async (signal?: string) => { + observedSignal = signal; + throw shutdownError; + }; + + try { + process.emit('SIGTERM', 'SIGTERM'); + await shutdownLogged.promise; + + expect(observedSignal).toBe('SIGTERM'); + expect(process.exitCode).toBe(1); + expect(loggerEvents).toContain('error:FluoFactory:Failed to shut down the application cleanly.:close failed'); + } finally { + app.close = originalClose; + await app.close(); + process.exitCode = originalExitCode; + } + }); + it('supports https startup and reports the https listen URL', async () => { const loggerEvents: string[] = []; const logger: ApplicationLogger = { diff --git a/packages/runtime/src/node/node-request.test.ts b/packages/runtime/src/node/node-request.test.ts index d4eaf0491..a7b651953 100644 --- a/packages/runtime/src/node/node-request.test.ts +++ b/packages/runtime/src/node/node-request.test.ts @@ -195,6 +195,31 @@ describe('node request adapter', () => { ]); }); + it('uses maxBodySize as the Node multipart total-size fallback', async () => { + const form = new FormData(); + form.append('name', 'Ada Lovelace'); + form.append('payload', new Blob(['hello'], { type: 'text/plain' }), 'payload.txt'); + + const multipartRequest = new Request('http://localhost/uploads', { + body: form, + method: 'POST', + }); + const requestBody = Buffer.from(await multipartRequest.arrayBuffer()); + + const request = createIncomingMessage({ + body: requestBody, + headers: { + 'content-type': multipartRequest.headers.get('content-type') ?? undefined, + }, + method: 'POST', + url: '/uploads', + }); + + const result = createFrameworkRequest(request, new AbortController().signal, undefined, 10); + + await expect(result).rejects.toThrow('Multipart body exceeds the maximum size of 10 bytes.'); + }); + it('creates the request shell before materializing body and rawBody', async () => { let chunksRead = 0; const request = { diff --git a/packages/runtime/src/web.test.ts b/packages/runtime/src/web.test.ts index 7fa38a4ba..09eaa1323 100644 --- a/packages/runtime/src/web.test.ts +++ b/packages/runtime/src/web.test.ts @@ -246,6 +246,38 @@ describe('dispatchWebRequest', () => { expect(producedChunks).toBeLessThanOrEqual(3); }); + it('uses maxBodySize as the Web multipart total-size fallback', async () => { + const boundary = 'fluo-boundary'; + const body = `--${boundary}\r\ncontent-disposition: form-data; name="name"\r\n\r\nAda Lovelace\r\n--${boundary}--\r\n`; + + const response = await dispatchWebRequest({ + dispatcher: { + async dispatch() { + throw new Error('should not dispatch oversized multipart request'); + }, + }, + maxBodySize: 10, + request: new Request('https://runtime.test/upload', { + body, + headers: { + 'content-length': String(Buffer.byteLength(body)), + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'x-request-id': 'req-web-multipart-fallback', + }, + method: 'POST', + }), + }); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ + error: { + message: 'Multipart body exceeds the maximum size of 10 bytes.', + requestId: 'req-web-multipart-fallback', + status: 413, + }, + }); + }); + it('reuses an injected web factory without sharing request-specific state', async () => { const factory = createWebRequestResponseFactory({ rawBody: true }); const seenBodies: unknown[] = [];