mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in. F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage. Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
|
||||
function context(req: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
guard.canActivate(context({ headers: {}, cookies: {} }));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(401);
|
||||
expect((thrown as HttpException).getResponse()).toEqual({
|
||||
error: 'Access token required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function mockHost() {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
|
||||
const host = { switchToHttp: () => ({ getResponse: () => res }) } as never;
|
||||
return { res, host };
|
||||
}
|
||||
|
||||
describe('TrekExceptionFilter', () => {
|
||||
const filter = new TrekExceptionFilter();
|
||||
|
||||
it('passes through { error, code } bodies (auth guards) unchanged', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401), host);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('normalises a string HttpException to { error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException('Bad thing', 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad thing' });
|
||||
});
|
||||
|
||||
it('maps unknown errors to 500 { error: Internal server error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new Error('boom'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { HealthService } from '../../../src/nest/health/health.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
describe('Nest dependency injection (vitest + swc)', () => {
|
||||
it('injects HealthService + DatabaseService into HealthController by type', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const controller = moduleRef.get(HealthController);
|
||||
expect(controller.getHealth()).toEqual({
|
||||
ok: true,
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
|
||||
|
||||
describe('strangler toggle', () => {
|
||||
const original = process.env.NEST_PREFIXES;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.NEST_PREFIXES;
|
||||
else process.env.NEST_PREFIXES = original;
|
||||
});
|
||||
|
||||
it('defaults to /api/_nest when NEST_PREFIXES is unset', () => {
|
||||
delete process.env.NEST_PREFIXES;
|
||||
expect(getNestPrefixes()).toEqual(['/api/_nest']);
|
||||
});
|
||||
|
||||
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
|
||||
process.env.NEST_PREFIXES = '/api/weather, /api/airports';
|
||||
expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
|
||||
});
|
||||
|
||||
it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
|
||||
process.env.NEST_PREFIXES = '';
|
||||
expect(getNestPrefixes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches exact prefixes and subpaths but not lookalikes', () => {
|
||||
const match = makeNestPathMatcher(['/api/_nest']);
|
||||
expect(match('/api/_nest')).toBe(true);
|
||||
expect(match('/api/_nest/health')).toBe(true);
|
||||
expect(match('/api/_nestxyz')).toBe(false);
|
||||
expect(match('/api/health')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AppModule } from '../../../src/nest/app.module';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
|
||||
function ctx(user: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
|
||||
}
|
||||
|
||||
describe('AppModule wiring', () => {
|
||||
it('compiles with the global filter + DB provider and resolves the controller', async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(DatabaseService)
|
||||
.useValue({ get: () => ({ n: 0 }) })
|
||||
.compile();
|
||||
expect(moduleRef.get(HealthController)).toBeInstanceOf(HealthController);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const guard = new AdminGuard();
|
||||
it('allows admins', () => {
|
||||
expect(guard.canActivate(ctx({ role: 'admin' }))).toBe(true);
|
||||
});
|
||||
it('blocks non-admins and anonymous with 403 { error }', () => {
|
||||
expect(() => guard.canActivate(ctx({ role: 'user' }))).toThrow(HttpException);
|
||||
expect(() => guard.canActivate(ctx(undefined))).toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseService (shared connection)', () => {
|
||||
it('runs real queries against the existing SQLite connection', () => {
|
||||
const svc = new DatabaseService();
|
||||
expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
|
||||
expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
describe('ZodValidationPipe', () => {
|
||||
const pipe = new ZodValidationPipe(z.object({ name: z.string().min(1) }));
|
||||
const meta = {} as never;
|
||||
|
||||
it('returns the parsed value for valid input', () => {
|
||||
expect(pipe.transform({ name: 'x' }, meta)).toEqual({ name: 'x' });
|
||||
});
|
||||
|
||||
it('throws TREK { error } envelope with status 400 on invalid input', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
pipe.transform({ name: '' }, meta);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(400);
|
||||
expect((thrown as HttpException).getResponse()).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user