test(security): TST-012 HTTP-level security test suite (auth, headers, CORS, CASL, route enum)
This commit is contained in:
parent
1c784b654f
commit
e22215ab64
1 changed files with 236 additions and 0 deletions
236
src/test/security.spec.ts
Normal file
236
src/test/security.spec.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||||
|
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<string, unknown>).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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue