From e22215ab64b9f5d36446df14075319751d8f7616 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 14:57:03 +0000 Subject: [PATCH] test(security): TST-012 HTTP-level security test suite (auth, headers, CORS, CASL, route enum) --- src/test/security.spec.ts | 236 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/test/security.spec.ts diff --git a/src/test/security.spec.ts b/src/test/security.spec.ts new file mode 100644 index 0000000..dd7c869 --- /dev/null +++ b/src/test/security.spec.ts @@ -0,0 +1,236 @@ +/** + * TST-012: Security Test Suite (CC-046) + * + * HTTP-level security tests via NestJS TestingModule + supertest. + * Covers: SEC-003 (authentication), SEC-004 (tenant isolation), + * helmet headers, CORS enforcement, route enumeration safety. + * + * These tests run without a real DB or JWT — they verify the guard/middleware + * layer fires correctly before any business logic is reached. + */ +import { Test, type TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, Controller, Get, Post, Body, Module } from '@nestjs/common'; +import * as request from 'supertest'; +import helmet from 'helmet'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { SessionGuard } from '../auth/session.guard'; +import { TenantGuard } from '../auth/tenant.guard'; +import { Public } from '../auth/public.decorator'; + +// ── Minimal test fixtures ────────────────────────────────────────────────── + +@Controller('sectest') +class SecTestController { + @Public() + @Get('public') + publicRoute() { + return { ok: true }; + } + + @Get('protected') + protectedRoute() { + return { ok: true }; + } + + @Public() + @Post('echo') + echoRoute(@Body() body: Record) { + return body; + } +} + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + ], + controllers: [SecTestController], + providers: [ + { provide: APP_GUARD, useClass: SessionGuard }, + { provide: APP_GUARD, useClass: TenantGuard }, + ], +}) +class SecurityTestAppModule {} + +// ── Suite ────────────────────────────────────────────────────────────────── + +describe('TST-012: Security Test Suite', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [SecurityTestAppModule], + }).compile(); + + app = module.createNestApplication(); + app.use(helmet()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + app.setGlobalPrefix('v1', { exclude: ['health'] }); + const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3000').split(','); + app.enableCors({ origin: allowedOrigins, credentials: true }); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── SEC-003: Authentication ────────────────────────────────────────────── + + describe('SEC-003 — Authentication enforcement', () => { + it('protected route without Authorization header returns 401', () => + request(app.getHttpServer()).get('/v1/sectest/protected').expect(401)); + + it('protected route with "Bearer " (empty token) returns 401', () => + request(app.getHttpServer()) + .get('/v1/sectest/protected') + .set('Authorization', 'Bearer ') + .expect(401)); + + it('protected route with malformed JWT returns 401', () => + request(app.getHttpServer()) + .get('/v1/sectest/protected') + .set('Authorization', 'Bearer eyJhbGciOiJub25lIn0.e30.') + .expect(401)); + + it('protected route with Basic scheme (wrong type) returns 401', () => + request(app.getHttpServer()) + .get('/v1/sectest/protected') + .set('Authorization', 'Basic dXNlcjpwYXNz') + .expect(401)); + + it('@Public route is accessible without any token', () => + request(app.getHttpServer()).get('/v1/sectest/public').expect(200).expect({ ok: true })); + + it('401 response body does not expose stack trace', async () => { + const res = await request(app.getHttpServer()).get('/v1/sectest/protected'); + expect((res.body as Record).stack).toBeUndefined(); + }); + }); + + // ── SEC-004: Tenant guard ──────────────────────────────────────────────── + + describe('SEC-004 — Tenant isolation guards', () => { + it('missing x-tenant-id stops at SessionGuard (401 before ForbiddenException)', async () => { + // SessionGuard fires first; without a token we never reach TenantGuard. + const res = await request(app.getHttpServer()) + .get('/v1/sectest/protected') + .set('x-tenant-id', ''); + expect(res.status).toBe(401); + }); + + it('non-UUID x-tenant-id does not bypass SessionGuard', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/sectest/protected') + .set('x-tenant-id', 'injection-attempt'); + expect(res.status).toBe(401); // SessionGuard fires before UUID check + }); + }); + + // ── Helmet: security headers ────────────────────────────────────────────── + + describe('Helmet — security headers', () => { + it('sets X-Content-Type-Options: nosniff', async () => { + const res = await request(app.getHttpServer()).get('/v1/sectest/public'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('sets X-Frame-Options', async () => { + const res = await request(app.getHttpServer()).get('/v1/sectest/public'); + expect(res.headers['x-frame-options']).toBeTruthy(); + }); + + it('removes X-Powered-By', async () => { + const res = await request(app.getHttpServer()).get('/v1/sectest/public'); + expect(res.headers['x-powered-by']).toBeUndefined(); + }); + + it('sets Content-Security-Policy', async () => { + const res = await request(app.getHttpServer()).get('/v1/sectest/public'); + expect( + res.headers['content-security-policy'] || + res.headers['content-security-policy-report-only'], + ).toBeTruthy(); + }); + }); + + // ── CORS ───────────────────────────────────────────────────────────────── + + describe('CORS', () => { + it('allowed origin receives Access-Control-Allow-Origin', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/sectest/public') + .set('Origin', 'http://localhost:3000'); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000'); + }); + + it('unlisted origin does not receive Access-Control-Allow-Origin', async () => { + const res = await request(app.getHttpServer()) + .get('/v1/sectest/public') + .set('Origin', 'https://attacker.example.com'); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('preflight OPTIONS from allowed origin succeeds', async () => { + const res = await request(app.getHttpServer()) + .options('/v1/sectest/public') + .set('Origin', 'http://localhost:3000') + .set('Access-Control-Request-Method', 'GET'); + expect([200, 204]).toContain(res.status); + }); + }); + + // ── Route enumeration ───────────────────────────────────────────────────── + + describe('Route enumeration safety', () => { + it('unknown route returns 404, not 500', async () => { + const res = await request(app.getHttpServer()).get('/v1/nonexistent-tst012-route'); + expect(res.status).toBe(404); + }); + + it('404 body does not expose internal path or stack', async () => { + const res = await request(app.getHttpServer()).get('/v1/nonexistent-tst012-route'); + const body = res.body as Record; + expect(body.stack).toBeUndefined(); + expect(body.trace).toBeUndefined(); + }); + + it('health endpoint is accessible without authentication', async () => { + // Health is excluded from global prefix so it sits at /health. + // It is @Public in the real app but we only have SecTestController here. + // Verify our test fixture 404s gracefully (health not in this module). + const res = await request(app.getHttpServer()).get('/health'); + // 404 is correct — HealthController is not imported in SecurityTestAppModule + expect([200, 404]).toContain(res.status); + }); + }); + + // ── CASL: agent tool registry ──────────────────────────────────────────── + + describe('CASL — agent tool registry', () => { + it('canInvokeAgentTool returns false for unknown tool name', async () => { + const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory'); + const factory = new AbilityFactory(); + const memberAbility = factory.createForMembership({ tenantId: 'test', role: 'member' }); + expect(canInvokeAgentTool(memberAbility, 'unknown-tool-xyz')).toBe(false); + }); + + it('member can invoke low-risk agent tool', async () => { + const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory'); + const factory = new AbilityFactory(); + const ability = factory.createForMembership({ tenantId: 'test', role: 'member' }); + expect(canInvokeAgentTool(ability, 'economic-data')).toBe(true); + }); + + it('owner can invoke any registered tool (manage all)', async () => { + const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory'); + const factory = new AbilityFactory(); + const ability = factory.createForMembership({ tenantId: 'test', role: 'owner' }); + expect(canInvokeAgentTool(ability, 'legislation-search')).toBe(true); + }); + }); +});