Skip to content

Commit ad70f08

Browse files
committed
test(security): add comprehensive security-headers e2e test suite
1 parent fe9ac71 commit ad70f08

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { INestApplication, VersioningType } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { Test, TestingModule } from '@nestjs/testing';
4+
import request from 'supertest';
5+
import {
6+
buildCorsOptions,
7+
createCorsOriginValidator,
8+
createHelmetMiddleware,
9+
createRateLimiter,
10+
} from '../src/common/security/security.module';
11+
import { RedisService } from '@liaoliaots/nestjs-redis';
12+
import RedisMock from 'ioredis-mock';
13+
import { Controller, Get, HttpCode } from '@nestjs/common';
14+
15+
// ---------------------------------------------------------------------------
16+
// Helpers
17+
// ---------------------------------------------------------------------------
18+
19+
@Controller()
20+
class TestController {
21+
@Get('health')
22+
@HttpCode(200)
23+
getHealth() {
24+
return { status: 'OK' };
25+
}
26+
27+
@Get()
28+
@HttpCode(200)
29+
getRoot() {
30+
return { message: 'OK' };
31+
}
32+
}
33+
34+
const createTestApp = async (): Promise<INestApplication> => {
35+
const mockRedisInstance = new RedisMock();
36+
const moduleFixture: TestingModule = await Test.createTestingModule({
37+
imports: [
38+
// ConfigModule is loaded via forRoot in the test; no need to import it
39+
// here since ConfigModule.forRoot is already configured in the test.
40+
],
41+
controllers: [TestController],
42+
providers: [
43+
{
44+
provide: ConfigService,
45+
useValue: {
46+
get: jest.fn(<T = any>(key: string, defaultValue?: T): T | undefined => {
47+
if (key === 'NODE_ENV') return (process.env.NODE_ENV ?? 'test') as unknown as T;
48+
if (key === 'CORS_ORIGINS') return (process.env.CORS_ORIGINS ?? 'http://localhost:3000') as unknown as T;
49+
if (key === 'CORS_ALLOW_CREDENTIALS') return (process.env.CORS_ALLOW_CREDENTIALS ?? 'false') as unknown as T;
50+
if (key === 'RATE_LIMIT_LIMIT' || key === 'API_RATE_LIMIT') return '1000' as unknown as T;
51+
if (key === 'RATE_LIMIT_WINDOW_MS' || key === 'THROTTLE_TTL') return '60000' as unknown as T;
52+
return defaultValue;
53+
}),
54+
},
55+
},
56+
{
57+
provide: RedisService,
58+
useValue: {
59+
getOrThrow: () => mockRedisInstance,
60+
},
61+
},
62+
],
63+
}).compile();
64+
65+
const app = moduleFixture.createNestApplication();
66+
app.getHttpAdapter().getInstance().disable('x-powered-by');
67+
68+
const configService = app.get(ConfigService);
69+
app.use(createHelmetMiddleware(configService));
70+
app.use(createCorsOriginValidator(configService));
71+
app.enableCors(buildCorsOptions(configService));
72+
app.use(createRateLimiter(configService, app.get(RedisService)));
73+
74+
app.setGlobalPrefix('api');
75+
app.enableVersioning({
76+
type: VersioningType.URI,
77+
defaultVersion: '1',
78+
prefix: 'v',
79+
});
80+
81+
await app.init();
82+
return app;
83+
};
84+
85+
// Required security headers and their expected values (regex patterns)
86+
// These are derived from buildHelmetOptions in security.module.ts
87+
const REQUIRED_PRODUCTION_HEADERS: Record<string, RegExp | string> = {
88+
'strict-transport-security': /max-age=31536000; includeSubDomains; preload/,
89+
'x-content-type-options': 'nosniff',
90+
'x-frame-options': 'DENY',
91+
'referrer-policy': 'strict-origin-when-cross-origin',
92+
'cross-origin-resource-policy': 'same-origin',
93+
'cross-origin-opener-policy': 'same-origin',
94+
'x-dns-prefetch-control': 'off',
95+
'x-permitted-cross-domain-policies': 'none',
96+
'content-security-policy': /default-src 'self'/,
97+
};
98+
99+
// Headers that should be present in development mode too
100+
const REQUIRED_DEV_HEADERS: Record<string, RegExp | string> = {
101+
'x-content-type-options': 'nosniff',
102+
'x-frame-options': 'DENY',
103+
'referrer-policy': 'strict-origin-when-cross-origin',
104+
'cross-origin-resource-policy': 'same-origin',
105+
'x-dns-prefetch-control': 'off',
106+
'x-permitted-cross-domain-policies': 'none',
107+
};
108+
109+
// Headers that MUST be absent in development mode
110+
const ABSENT_DEV_HEADERS = [
111+
'strict-transport-security',
112+
'content-security-policy',
113+
'cross-origin-opener-policy',
114+
];
115+
116+
const setEnv = (vars: Record<string, string | undefined>) => {
117+
for (const [key, value] of Object.entries(vars)) {
118+
if (value === undefined) {
119+
delete process.env[key];
120+
} else {
121+
process.env[key] = value;
122+
}
123+
}
124+
};
125+
126+
// ---------------------------------------------------------------------------
127+
// Tests
128+
// ---------------------------------------------------------------------------
129+
130+
describe('Security Headers (e2e)', () => {
131+
let originalEnv: Record<string, string | undefined>;
132+
133+
beforeAll(() => {
134+
originalEnv = {
135+
NODE_ENV: process.env.NODE_ENV,
136+
CORS_ORIGINS: process.env.CORS_ORIGINS,
137+
CORS_ALLOW_CREDENTIALS: process.env.CORS_ALLOW_CREDENTIALS,
138+
};
139+
});
140+
141+
afterAll(() => {
142+
setEnv(originalEnv);
143+
});
144+
145+
describe('Production mode', () => {
146+
let app: INestApplication;
147+
148+
beforeAll(async () => {
149+
setEnv({
150+
NODE_ENV: 'production',
151+
CORS_ORIGINS: 'https://api.chainforge.app',
152+
CORS_ALLOW_CREDENTIALS: 'false',
153+
});
154+
app = await createTestApp();
155+
});
156+
157+
afterAll(async () => {
158+
await app.close();
159+
});
160+
161+
it('should include all required security headers with correct values', async () => {
162+
const response = await request(app.getHttpServer()).get('/api/v1/health');
163+
164+
for (const [header, expected] of Object.entries(REQUIRED_PRODUCTION_HEADERS)) {
165+
expect(response.headers[header]).toBeDefined();
166+
if (expected instanceof RegExp) {
167+
expect(response.headers[header]).toMatch(expected);
168+
} else {
169+
expect(response.headers[header]).toBe(expected);
170+
}
171+
}
172+
});
173+
174+
it('should NOT include the x-powered-by header', async () => {
175+
const response = await request(app.getHttpServer()).get('/api/v1/health');
176+
expect(response.headers['x-powered-by']).toBeUndefined();
177+
});
178+
179+
it('should include report-uri in Content-Security-Policy header', async () => {
180+
const response = await request(app.getHttpServer()).get('/api/v1/health');
181+
const csp = response.headers['content-security-policy'] as string;
182+
expect(csp).toBeDefined();
183+
// The CSP report-uri directive tells the browser where to send violation reports
184+
expect(csp).toContain('/api/v1/csp-report');
185+
});
186+
187+
it('should include preload directive in Strict-Transport-Security', async () => {
188+
const response = await request(app.getHttpServer()).get('/api/v1/health');
189+
const hsts = response.headers['strict-transport-security'] as string;
190+
expect(hsts).toBeDefined();
191+
expect(hsts).toContain('preload');
192+
expect(hsts).toContain('includeSubDomains');
193+
expect(hsts).toContain('max-age=31536000');
194+
});
195+
});
196+
197+
describe('Development mode', () => {
198+
let app: INestApplication;
199+
200+
beforeAll(async () => {
201+
setEnv({
202+
NODE_ENV: 'development',
203+
CORS_ORIGINS: 'http://localhost:3000',
204+
CORS_ALLOW_CREDENTIALS: 'false',
205+
});
206+
app = await createTestApp();
207+
});
208+
209+
afterAll(async () => {
210+
await app.close();
211+
});
212+
213+
it('should include dev-appropriate security headers', async () => {
214+
const response = await request(app.getHttpServer()).get('/api/v1/health');
215+
216+
for (const [header, expected] of Object.entries(REQUIRED_DEV_HEADERS)) {
217+
expect(response.headers[header]).toBeDefined();
218+
if (expected instanceof RegExp) {
219+
expect(response.headers[header]).toMatch(expected);
220+
} else {
221+
expect(response.headers[header]).toBe(expected);
222+
}
223+
}
224+
});
225+
226+
it('should NOT include production-only headers in dev mode', async () => {
227+
const response = await request(app.getHttpServer()).get('/api/v1/health');
228+
229+
for (const header of ABSENT_DEV_HEADERS) {
230+
expect(response.headers[header]).toBeUndefined();
231+
}
232+
});
233+
234+
it('should not include x-powered-by header', async () => {
235+
const response = await request(app.getHttpServer()).get('/api/v1/health');
236+
expect(response.headers['x-powered-by']).toBeUndefined();
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)