/** * E2E integration test — POST /api/bootstrap/setup * * Regression guard for the `import type { BootstrapSetupDto }` class-erasure * bug (IUV-M01, issue #436). * * When `BootstrapSetupDto` is imported with `import type`, TypeScript erases * the class at compile time. NestJS then sees `Object` as the `@Body()` * metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true` * treats every property as non-whitelisted, returning: * * 400 { message: ["property email should not exist", "property password should not exist"] } * * The fix is a plain value import (`import { BootstrapSetupDto }`), which * preserves the class reference so Nest can read the class-validator decorators. * * This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`. * A controller unit test that constructs ValidationPipe manually won't catch * this — only the real DI binding path exercises the metatype lookup. */ import 'reflect-metadata'; import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { Test } from '@nestjs/testing'; import { ValidationPipe, type INestApplication } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import request from 'supertest'; import { BootstrapController } from './bootstrap.controller.js'; import type { BootstrapResultDto } from './bootstrap.dto.js'; // ─── Minimal mock dependencies ─────────────────────────────────────────────── /** * We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we * can provide mock values by token without spinning up the real DB or Auth. */ import { AUTH } from '../auth/auth.tokens.js'; import { DB } from '../database/database.module.js'; const MOCK_USER_ID = 'mock-user-id-001'; const mockAuth = { api: { createUser: () => Promise.resolve({ user: { id: MOCK_USER_ID, name: 'Admin', email: 'admin@example.com', }, }), }, }; // Override db.select() so the second query (verify user exists) returns a user. // The bootstrap controller calls select().from() twice: // 1. count() to check zero users → returns [{total: 0}] // 2. select().where().limit() → returns [the created user] let selectCallCount = 0; const mockDbWithUser = { select: () => { selectCallCount++; return { from: () => { if (selectCallCount === 1) { // First call: count — zero users return Promise.resolve([{ total: 0 }]); } // Subsequent calls: return a mock user row return { where: () => ({ limit: () => Promise.resolve([ { id: MOCK_USER_ID, name: 'Admin', email: 'admin@example.com', role: 'admin', }, ]), }), }; }, }; }, update: () => ({ set: () => ({ where: () => Promise.resolve([]), }), }), insert: () => ({ values: () => ({ returning: () => Promise.resolve([ { id: 'token-id-001', label: 'Initial setup token', }, ]), }), }), }; // ─── Test suite ─────────────────────────────────────────────────────────────── describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => { let app: INestApplication; beforeAll(async () => { selectCallCount = 0; const moduleRef = await Test.createTestingModule({ controllers: [BootstrapController], providers: [ { provide: AUTH, useValue: mockAuth }, { provide: DB, useValue: mockDbWithUser }, ], }).compile(); app = moduleRef.createNestApplication(new FastifyAdapter()); // Mirror main.ts configuration exactly — this is what reproduced the 400. app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); await app.init(); // Fastify requires waiting for the adapter to be ready await app.getHttpAdapter().getInstance().ready(); }); afterAll(async () => { await app.close(); }); it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => { const res = await request(app.getHttpServer()) .post('/api/bootstrap/setup') .send({ name: 'Admin', email: 'admin@example.com', password: 'password123' }) .set('Content-Type', 'application/json'); // Before the fix (import type), Nest ValidationPipe returned 400 with // "property email should not exist" / "property password should not exist" // because the DTO class was erased and every field looked non-whitelisted. expect(res.status).not.toBe(400); expect(res.status).toBe(201); const body = res.body as BootstrapResultDto; expect(body.user).toBeDefined(); expect(body.user.email).toBe('admin@example.com'); expect(body.token).toBeDefined(); expect(body.token.plaintext).toBeDefined(); }); it('returns 400 when extra forbidden properties are sent', async () => { // This proves ValidationPipe IS active and working (forbidNonWhitelisted). const res = await request(app.getHttpServer()) .post('/api/bootstrap/setup') .send({ name: 'Admin', email: 'admin@example.com', password: 'password123', extraField: 'should-be-rejected', }) .set('Content-Type', 'application/json'); expect(res.status).toBe(400); }); it('returns 400 when email is invalid', async () => { const res = await request(app.getHttpServer()) .post('/api/bootstrap/setup') .send({ name: 'Admin', email: 'not-an-email', password: 'password123' }) .set('Content-Type', 'application/json'); expect(res.status).toBe(400); }); it('returns 400 when password is too short', async () => { const res = await request(app.getHttpServer()) .post('/api/bootstrap/setup') .send({ name: 'Admin', email: 'admin@example.com', password: 'short' }) .set('Content-Type', 'application/json'); expect(res.status).toBe(400); }); });