diff --git a/src/test/security.spec.ts b/src/test/security.spec.ts index dd7c869..d826f0d 100644 --- a/src/test/security.spec.ts +++ b/src/test/security.spec.ts @@ -4,13 +4,10 @@ * 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 request from 'supertest'; 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'; @@ -82,29 +79,37 @@ describe('TST-012: Security Test Suite', () => { // ── 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 without Authorization header returns 401', async () => { + await request(app.getHttpServer()).get('/v1/sectest/protected').expect(401); + }); - it('protected route with "Bearer " (empty token) returns 401', () => - request(app.getHttpServer()) + it('protected route with "Bearer " (empty token) returns 401', async () => { + await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('Authorization', 'Bearer ') - .expect(401)); + .expect(401); + }); - it('protected route with malformed JWT returns 401', () => - request(app.getHttpServer()) + it('protected route with malformed JWT returns 401', async () => { + await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('Authorization', 'Bearer eyJhbGciOiJub25lIn0.e30.') - .expect(401)); + .expect(401); + }); - it('protected route with Basic scheme (wrong type) returns 401', () => - request(app.getHttpServer()) + it('protected route with Basic scheme (wrong type) returns 401', async () => { + await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('Authorization', 'Basic dXNlcjpwYXNz') - .expect(401)); + .expect(401); + }); - it('@Public route is accessible without any token', () => - request(app.getHttpServer()).get('/v1/sectest/public').expect(200).expect({ ok: true })); + it('@Public route is accessible without any token', async () => { + await 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'); @@ -115,19 +120,18 @@ describe('TST-012: Security Test Suite', () => { // ── 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. + it('missing x-tenant-id is caught by SessionGuard first (returns 401)', async () => { 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 () => { + it('injection attempt in 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 + .set('x-tenant-id', "'; DROP TABLE memberships; --"); + expect(res.status).toBe(401); }); }); @@ -192,21 +196,12 @@ describe('TST-012: Security Test Suite', () => { expect(res.status).toBe(404); }); - it('404 body does not expose internal path or stack', async () => { + it('404 body does not expose stack trace or internal paths', 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 ──────────────────────────────────────────── @@ -215,11 +210,11 @@ describe('TST-012: Security Test Suite', () => { 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); + const ability = factory.createForMembership({ tenantId: 'test', role: 'member' }); + expect(canInvokeAgentTool(ability, 'unknown-tool-xyz')).toBe(false); }); - it('member can invoke low-risk agent tool', async () => { + it('member can invoke low-risk agent tool (economic-data)', async () => { const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory'); const factory = new AbilityFactory(); const ability = factory.createForMembership({ tenantId: 'test', role: 'member' }); @@ -232,5 +227,15 @@ describe('TST-012: Security Test Suite', () => { const ability = factory.createForMembership({ tenantId: 'test', role: 'owner' }); expect(canInvokeAgentTool(ability, 'legislation-search')).toBe(true); }); + + it('member cannot invoke unlisted high-risk tool', async () => { + const { canInvokeAgentTool, AbilityFactory, AGENT_TOOL_REGISTRY } = await import('../auth/ability.factory'); + const factory = new AbilityFactory(); + const ability = factory.createForMembership({ tenantId: 'test', role: 'member' }); + // A tool not in registry is always denied + const registeredNames = new Set(AGENT_TOOL_REGISTRY.map((t) => t.name)); + expect(canInvokeAgentTool(ability, 'database-writer-critical')).toBe(false); + expect(registeredNames.has('database-writer-critical')).toBe(false); + }); }); });