191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
/**
|
|
* 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<NestFastifyApplication>(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);
|
|
});
|
|
});
|