From 020b0c21da273c945ed91238d835e0dc19cbfdfe Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Fri, 31 Jul 2026 15:03:40 +0000 Subject: [PATCH] fix(test): mock db+supabase at module level so SessionGuard loads without real DB in TST-012 --- src/test/security.spec.ts | 58 +++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/test/security.spec.ts b/src/test/security.spec.ts index d826f0d..1d6254b 100644 --- a/src/test/security.spec.ts +++ b/src/test/security.spec.ts @@ -2,12 +2,36 @@ * 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. + * Covers: SEC-003 (authentication), SEC-004 (tenant isolation guards), + * helmet headers, CORS enforcement, route enumeration safety, + * CASL agent tool registry policy. + * + * DB and Supabase are mocked — no real connections needed. + * JWT verification uses the real jose/GoTrue path but without a valid + * SUPABASE_JWT_SECRET, so any bearer token throws UnauthorizedException. */ + +// Mock DB and Supabase before any guard modules are loaded +jest.mock('../db/client', () => ({ db: { query: {} } })); +jest.mock('../supabase/supabase.client', () => ({ + supabaseAdmin: { + auth: { + getUser: async () => ({ data: { user: null }, error: new Error('mocked') }), + }, + }, +})); + import request from 'supertest'; import { Test, type TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe, Controller, Get, Post, Body, Module } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + Controller, + Get, + Post, + Body, + Module, +} from '@nestjs/common'; import helmet from 'helmet'; import { ThrottlerModule } from '@nestjs/throttler'; import { ConfigModule } from '@nestjs/config'; @@ -83,7 +107,7 @@ describe('TST-012: Security Test Suite', () => { await request(app.getHttpServer()).get('/v1/sectest/protected').expect(401); }); - it('protected route with "Bearer " (empty token) returns 401', async () => { + it('protected route with empty bearer string returns 401', async () => { await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('Authorization', 'Bearer ') @@ -91,6 +115,7 @@ describe('TST-012: Security Test Suite', () => { }); it('protected route with malformed JWT returns 401', async () => { + // Mocked GoTrue returns error for any token — real behavior in prod await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('Authorization', 'Bearer eyJhbGciOiJub25lIn0.e30.') @@ -120,14 +145,14 @@ describe('TST-012: Security Test Suite', () => { // ── SEC-004: Tenant guard ──────────────────────────────────────────────── describe('SEC-004 — Tenant isolation guards', () => { - it('missing x-tenant-id is caught by SessionGuard first (returns 401)', async () => { + it('request without token is blocked by SessionGuard (401) before TenantGuard', async () => { const res = await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('x-tenant-id', ''); expect(res.status).toBe(401); }); - it('injection attempt in x-tenant-id does not bypass SessionGuard', async () => { + it('SQL injection attempt in x-tenant-id header does not bypass SessionGuard', async () => { const res = await request(app.getHttpServer()) .get('/v1/sectest/protected') .set('x-tenant-id', "'; DROP TABLE memberships; --"); @@ -157,7 +182,7 @@ describe('TST-012: Security Test Suite', () => { const res = await request(app.getHttpServer()).get('/v1/sectest/public'); expect( res.headers['content-security-policy'] || - res.headers['content-security-policy-report-only'], + res.headers['content-security-policy-report-only'], ).toBeTruthy(); }); }); @@ -179,7 +204,7 @@ describe('TST-012: Security Test Suite', () => { expect(res.headers['access-control-allow-origin']).toBeUndefined(); }); - it('preflight OPTIONS from allowed origin succeeds', async () => { + it('preflight OPTIONS from allowed origin succeeds (200 or 204)', async () => { const res = await request(app.getHttpServer()) .options('/v1/sectest/public') .set('Origin', 'http://localhost:3000') @@ -228,14 +253,17 @@ describe('TST-012: Security Test Suite', () => { 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'); + it('tool absent from registry is always denied regardless of role', 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); + const ownerAbility = factory.createForMembership({ tenantId: 'test', role: 'owner' }); + const memberAbility = factory.createForMembership({ tenantId: 'test', role: 'member' }); + // Deny-by-default for unregistered tools + expect(canInvokeAgentTool(ownerAbility, 'database-writer-critical')).toBe(false); + expect(canInvokeAgentTool(memberAbility, 'database-writer-critical')).toBe(false); + expect(AGENT_TOOL_REGISTRY.map((t) => t.name)).not.toContain('database-writer-critical'); }); }); });