fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, Pi SDK copy (mosaic-v0.0.26) (#440)
This commit was merged in pull request #440.
This commit is contained in:
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user