fix(test): default import for supertest (esModuleInterop), add CASL member high-risk denial test
This commit is contained in:
parent
e3b5cca72c
commit
694ba7a5a6
1 changed files with 40 additions and 35 deletions
|
|
@ -4,13 +4,10 @@
|
||||||
* 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),
|
||||||
* helmet headers, CORS enforcement, route enumeration safety.
|
* 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 { 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 * as request from 'supertest';
|
|
||||||
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';
|
||||||
|
|
@ -82,29 +79,37 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
// ── SEC-003: Authentication ──────────────────────────────────────────────
|
// ── SEC-003: Authentication ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('SEC-003 — Authentication enforcement', () => {
|
describe('SEC-003 — Authentication enforcement', () => {
|
||||||
it('protected route without Authorization header returns 401', () =>
|
it('protected route without Authorization header returns 401', async () => {
|
||||||
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', () =>
|
it('protected route with "Bearer " (empty token) returns 401', async () => {
|
||||||
request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('Authorization', 'Bearer ')
|
.set('Authorization', 'Bearer ')
|
||||||
.expect(401));
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
it('protected route with malformed JWT returns 401', () =>
|
it('protected route with malformed JWT returns 401', async () => {
|
||||||
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.')
|
||||||
.expect(401));
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
it('protected route with Basic scheme (wrong type) returns 401', () =>
|
it('protected route with Basic scheme (wrong type) returns 401', async () => {
|
||||||
request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('Authorization', 'Basic dXNlcjpwYXNz')
|
.set('Authorization', 'Basic dXNlcjpwYXNz')
|
||||||
.expect(401));
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
it('@Public route is accessible without any token', () =>
|
it('@Public route is accessible without any token', async () => {
|
||||||
request(app.getHttpServer()).get('/v1/sectest/public').expect(200).expect({ ok: true }));
|
await request(app.getHttpServer())
|
||||||
|
.get('/v1/sectest/public')
|
||||||
|
.expect(200)
|
||||||
|
.expect({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('401 response body does not expose stack trace', async () => {
|
it('401 response body does not expose stack trace', async () => {
|
||||||
const res = await request(app.getHttpServer()).get('/v1/sectest/protected');
|
const res = await request(app.getHttpServer()).get('/v1/sectest/protected');
|
||||||
|
|
@ -115,19 +120,18 @@ 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 stops at SessionGuard (401 before ForbiddenException)', async () => {
|
it('missing x-tenant-id is caught by SessionGuard first (returns 401)', async () => {
|
||||||
// SessionGuard fires first; without a token we never reach TenantGuard.
|
|
||||||
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('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())
|
const res = await request(app.getHttpServer())
|
||||||
.get('/v1/sectest/protected')
|
.get('/v1/sectest/protected')
|
||||||
.set('x-tenant-id', 'injection-attempt');
|
.set('x-tenant-id', "'; DROP TABLE memberships; --");
|
||||||
expect(res.status).toBe(401); // SessionGuard fires before UUID check
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -192,21 +196,12 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
expect(res.status).toBe(404);
|
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 res = await request(app.getHttpServer()).get('/v1/nonexistent-tst012-route');
|
||||||
const body = res.body as Record<string, unknown>;
|
const body = res.body as Record<string, unknown>;
|
||||||
expect(body.stack).toBeUndefined();
|
expect(body.stack).toBeUndefined();
|
||||||
expect(body.trace).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 ────────────────────────────────────────────
|
// ── CASL: agent tool registry ────────────────────────────────────────────
|
||||||
|
|
@ -215,11 +210,11 @@ describe('TST-012: Security Test Suite', () => {
|
||||||
it('canInvokeAgentTool returns false for unknown tool name', async () => {
|
it('canInvokeAgentTool returns false for unknown tool name', async () => {
|
||||||
const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory');
|
const { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory');
|
||||||
const factory = new AbilityFactory();
|
const factory = new AbilityFactory();
|
||||||
const memberAbility = factory.createForMembership({ tenantId: 'test', role: 'member' });
|
const ability = factory.createForMembership({ tenantId: 'test', role: 'member' });
|
||||||
expect(canInvokeAgentTool(memberAbility, 'unknown-tool-xyz')).toBe(false);
|
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 { canInvokeAgentTool, AbilityFactory } = await import('../auth/ability.factory');
|
||||||
const factory = new AbilityFactory();
|
const factory = new AbilityFactory();
|
||||||
const ability = factory.createForMembership({ tenantId: 'test', role: 'member' });
|
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' });
|
const ability = factory.createForMembership({ tenantId: 'test', role: 'owner' });
|
||||||
expect(canInvokeAgentTool(ability, 'legislation-search')).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue