Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 110 additions & 3 deletions packages/runtime/src/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!doctype html><html><body>Docs</body></html>';
const metricsBody = 'process_cpu_seconds_total 1';
Expand Down Expand Up @@ -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,
Expand All @@ -1301,20 +1351,77 @@ 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',
);
} 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<void>();
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 = {
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/src/node/node-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
32 changes: 32 additions & 0 deletions packages/runtime/src/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down