fix(test): mock db+supabase at module level so SessionGuard loads without real DB in TST-012
This commit is contained in:
parent
694ba7a5a6
commit
020b0c21da
1 changed files with 43 additions and 15 deletions
|
|
@ -2,12 +2,36 @@
|
||||||
* TST-012: Security Test Suite (CC-046)
|
* TST-012: Security Test Suite (CC-046)
|
||||||
*
|
*
|
||||||
* HTTP-level security tests via NestJS TestingModule + supertest.
|
* HTTP-level security tests via NestJS TestingModule + supertest.
|
||||||
* Covers: SEC-003 (authentication), SEC-004 (tenant isolation),
|
* Covers: SEC-003 (authentication), SEC-004 (tenant isolation guards),
|
||||||
* helmet headers, CORS enforcement, route enumeration safety.
|
* 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 request from 'supertest';
|
||||||
import { Test, type TestingModule } from '@nestjs/testing';
|
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 helmet from 'helmet';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
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);
|
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())
|
await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('Authorization', 'Bearer ')
|
.set('Authorization', 'Bearer ')
|
||||||
|
|
@ -91,6 +115,7 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('protected route with malformed JWT returns 401', async () => {
|
it('protected route with malformed JWT returns 401', async () => {
|
||||||
|
// Mocked GoTrue returns error for any token — real behavior in prod
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('Authorization', 'Bearer eyJhbGciOiJub25lIn0.e30.')
|
.set('Authorization', 'Bearer eyJhbGciOiJub25lIn0.e30.')
|
||||||
|
|
@ -120,14 +145,14 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
// ── SEC-004: Tenant guard ────────────────────────────────────────────────
|
// ── SEC-004: Tenant guard ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('SEC-004 — Tenant isolation guards', () => {
|
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())
|
const res = await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('x-tenant-id', '');
|
.set('x-tenant-id', '');
|
||||||
expect(res.status).toBe(401);
|
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())
|
const res = await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('x-tenant-id', "'; DROP TABLE memberships; --");
|
.set('x-tenant-id', "'; DROP TABLE memberships; --");
|
||||||
|
|
@ -179,7 +204,7 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
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())
|
const res = await request(app.getHttpServer())
|
||||||
.options('/v1/sectest/public')
|
.options('/v1/sectest/public')
|
||||||
.set('Origin', 'http://localhost:3000')
|
.set('Origin', 'http://localhost:3000')
|
||||||
|
|
@ -228,14 +253,17 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
expect(canInvokeAgentTool(ability, 'legislation-search')).toBe(true);
|
expect(canInvokeAgentTool(ability, 'legislation-search')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('member cannot invoke unlisted high-risk tool', async () => {
|
it('tool absent from registry is always denied regardless of role', async () => {
|
||||||
const { canInvokeAgentTool, AbilityFactory, AGENT_TOOL_REGISTRY } = await import('../auth/ability.factory');
|
const { canInvokeAgentTool, AbilityFactory, AGENT_TOOL_REGISTRY } = await import(
|
||||||
|
'../auth/ability.factory'
|
||||||
|
);
|
||||||
const factory = new AbilityFactory();
|
const factory = new AbilityFactory();
|
||||||
const ability = factory.createForMembership({ tenantId: 'test', role: 'member' });
|
const ownerAbility = factory.createForMembership({ tenantId: 'test', role: 'owner' });
|
||||||
// A tool not in registry is always denied
|
const memberAbility = factory.createForMembership({ tenantId: 'test', role: 'member' });
|
||||||
const registeredNames = new Set(AGENT_TOOL_REGISTRY.map((t) => t.name));
|
// Deny-by-default for unregistered tools
|
||||||
expect(canInvokeAgentTool(ability, 'database-writer-critical')).toBe(false);
|
expect(canInvokeAgentTool(ownerAbility, 'database-writer-critical')).toBe(false);
|
||||||
expect(registeredNames.has('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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue