mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
6c583c2f27
- One global ZodValidationPipe (APP_PIPE, createZodValidationPipe) validates
any @Body() typed with a createZodDto class; the custom exception factory
keeps TREK's { error: 'field: message; ...' } envelope byte-identical.
- The 7 piped controllers (collections, places, vacay, todo, health,
airtrail x2) now type bodies with colocated <domain>.dto.ts wrappers —
the Zod schemas in @trek/shared stay the single source of truth.
- OpenAPI: the hand-rolled api-zod.ts enricher is replaced by nestjs-zod's
cleanupOpenApiDoc; request bodies surface as $refs to named DTO schemas.
- validateBodyContracts() runs in buildApp() after init and refuses to boot
when any POST/PUT/PATCH @Body() (including @Body('field') sub-reads) lacks
a ZodDto metatype. The 175 pre-existing legacy handlers are grandfathered
in body-contract-allow-list.ts; the list is ratchet-only — the gate also
throws on stale entries, so migrating a domain forces their removal.
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
/**
|
|
* Unit tests for the fail-closed body-contract boot gate
|
|
* (src/nest/common/validate-body-contracts.ts): a POST/PUT/PATCH @Body()
|
|
* without a createZodDto metatype must refuse boot unless allow-listed, and
|
|
* stale allow-list entries must refuse boot too (the list only ratchets down).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { Body, Controller, Get, Post, Put } from '@nestjs/common';
|
|
import { Test } from '@nestjs/testing';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { createZodDto } from 'nestjs-zod';
|
|
import { z } from 'zod';
|
|
import { validateBodyContracts } from '../../../src/nest/common/validate-body-contracts';
|
|
|
|
class GoodDto extends createZodDto(z.object({ name: z.string() })) {}
|
|
|
|
@Controller('gate-test/good')
|
|
class GoodController {
|
|
@Post()
|
|
create(@Body() body: GoodDto) {
|
|
return body;
|
|
}
|
|
|
|
// Non-mutation and body-less handlers are out of scope for the gate.
|
|
@Get()
|
|
list() {
|
|
return [];
|
|
}
|
|
|
|
@Put('no-body/:id')
|
|
touch() {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
@Controller('gate-test/bad')
|
|
class BadController {
|
|
@Put(':id')
|
|
update(@Body() body: Record<string, unknown>) {
|
|
return body;
|
|
}
|
|
}
|
|
|
|
@Controller('gate-test/subfield')
|
|
class SubfieldController {
|
|
@Post()
|
|
reorder(@Body('orderedIds') orderedIds: number[]) {
|
|
return orderedIds;
|
|
}
|
|
}
|
|
|
|
async function buildApp(controllers: unknown[]): Promise<INestApplication> {
|
|
const moduleRef = await Test.createTestingModule({
|
|
controllers: controllers as never[],
|
|
}).compile();
|
|
return moduleRef.createNestApplication();
|
|
}
|
|
|
|
describe('validateBodyContracts (fail-closed boot gate)', () => {
|
|
let goodApp: INestApplication;
|
|
let badApp: INestApplication;
|
|
let subfieldApp: INestApplication;
|
|
|
|
beforeAll(async () => {
|
|
goodApp = await buildApp([GoodController]);
|
|
badApp = await buildApp([GoodController, BadController]);
|
|
subfieldApp = await buildApp([SubfieldController]);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await Promise.all([goodApp.close(), badApp.close(), subfieldApp.close()]);
|
|
});
|
|
|
|
it('GATE-001 — passes when every mutation body is a createZodDto class', () => {
|
|
expect(() => validateBodyContracts(goodApp, [])).not.toThrow();
|
|
});
|
|
|
|
it('GATE-002 — throws naming the handler whose @Body() lacks a DTO', () => {
|
|
expect(() => validateBodyContracts(badApp, [])).toThrow(/BadController\.update/);
|
|
});
|
|
|
|
it('GATE-003 — @Body(\'field\') sub-reads are unvalidated and flagged too', () => {
|
|
expect(() => validateBodyContracts(subfieldApp, [])).toThrow(/SubfieldController\.reorder/);
|
|
});
|
|
|
|
it('GATE-004 — an allow-listed legacy route passes', () => {
|
|
expect(() => validateBodyContracts(badApp, ['BadController.update'])).not.toThrow();
|
|
});
|
|
|
|
it('GATE-005 — a stale allow-list entry throws (ratchet enforcement)', () => {
|
|
expect(() => validateBodyContracts(goodApp, ['GhostController.gone'])).toThrow(
|
|
/stale body-contract allow-list entry.*GhostController\.gone/,
|
|
);
|
|
});
|
|
|
|
it('GATE-006 — the real allow-list is sorted and duplicate-free (reviewable ratchet)', async () => {
|
|
const { BODY_CONTRACT_ALLOW_LIST } = await import('../../../src/nest/common/body-contract-allow-list');
|
|
expect(BODY_CONTRACT_ALLOW_LIST).toEqual([...new Set(BODY_CONTRACT_ALLOW_LIST)].sort());
|
|
});
|
|
});
|