@dismissible/nestjs-api 0.0.2-canary.8976e84.0 → 0.0.2-canary.c91edbc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @dismissible/nestjs-api might be problematic. Click here for more details.
- package/config/.env.yaml +17 -1
- package/package.json +13 -10
- package/scripts/performance-test.config.json +29 -0
- package/scripts/performance-test.ts +855 -0
- package/src/app-test.factory.ts +8 -1
- package/src/app.e2e-spec.ts +1 -1
- package/src/app.setup.ts +36 -0
- package/src/bootstrap.ts +8 -3
- package/src/config/default-app.config.ts +10 -0
- package/src/cors/cors.config.spec.ts +162 -0
- package/src/cors/cors.config.ts +37 -0
- package/src/cors/index.ts +1 -0
- package/src/health/health.controller.spec.ts +7 -31
- package/src/health/health.controller.ts +4 -5
- package/src/health/health.module.ts +2 -3
- package/src/health/index.ts +0 -1
- package/src/helmet/helmet.config.spec.ts +197 -0
- package/src/helmet/helmet.config.ts +60 -0
- package/src/helmet/index.ts +1 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/transform-boolean.decorator.spec.ts +47 -0
- package/src/utils/transform-boolean.decorator.ts +19 -0
- package/src/utils/transform-comma-separated.decorator.ts +16 -0
- package/src/health/health.service.spec.ts +0 -46
- package/src/health/health.service.ts +0 -16
package/src/app-test.factory.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
|
2
2
|
import { INestApplication } from '@nestjs/common';
|
|
3
|
+
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
3
4
|
import { AppModule, AppModuleOptions } from './app.module';
|
|
4
5
|
import { configureApp } from './app.setup';
|
|
5
6
|
import { PrismaService } from '@dismissible/nestjs-postgres-storage';
|
|
@@ -19,10 +20,16 @@ export async function createTestApp(options?: TestAppOptions): Promise<INestAppl
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const moduleFixture = await builder.compile();
|
|
22
|
-
const app = moduleFixture.createNestApplication(
|
|
23
|
+
const app = moduleFixture.createNestApplication<NestFastifyApplication>(
|
|
24
|
+
new FastifyAdapter({
|
|
25
|
+
bodyLimit: 10 * 1024, // 10kb
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
23
28
|
await configureApp(app);
|
|
24
29
|
await app.init();
|
|
25
30
|
|
|
31
|
+
await app.getHttpAdapter().getInstance().ready();
|
|
32
|
+
|
|
26
33
|
return app;
|
|
27
34
|
}
|
|
28
35
|
|
package/src/app.e2e-spec.ts
CHANGED
package/src/app.setup.ts
CHANGED
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
3
|
+
import fastifyHelmet from '@fastify/helmet';
|
|
2
4
|
import { SwaggerConfig, configureAppWithSwagger } from './swagger';
|
|
5
|
+
import { CorsConfig } from './cors';
|
|
6
|
+
import { HelmetConfig } from './helmet';
|
|
3
7
|
|
|
4
8
|
export async function configureApp(app: INestApplication): Promise<void> {
|
|
9
|
+
const fastifyApp = app as NestFastifyApplication;
|
|
10
|
+
|
|
11
|
+
// Security headers via @fastify/helmet
|
|
12
|
+
const helmetConfig = app.get(HelmetConfig);
|
|
13
|
+
if (helmetConfig.enabled) {
|
|
14
|
+
await fastifyApp.register(fastifyHelmet, {
|
|
15
|
+
contentSecurityPolicy: helmetConfig.contentSecurityPolicy ?? true,
|
|
16
|
+
crossOriginEmbedderPolicy: helmetConfig.crossOriginEmbedderPolicy ?? true,
|
|
17
|
+
hsts: {
|
|
18
|
+
maxAge: helmetConfig.hstsMaxAge ?? 31536000, // 1 year
|
|
19
|
+
includeSubDomains: helmetConfig.hstsIncludeSubDomains ?? true,
|
|
20
|
+
preload: helmetConfig.hstsPreload ?? false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// CORS Configuration
|
|
26
|
+
const corsConfig = app.get(CorsConfig);
|
|
27
|
+
if (corsConfig.enabled) {
|
|
28
|
+
app.enableCors({
|
|
29
|
+
origin: corsConfig.origins ?? ['http://localhost:3000'],
|
|
30
|
+
methods: corsConfig.methods ?? ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
31
|
+
allowedHeaders: corsConfig.allowedHeaders ?? [
|
|
32
|
+
'Content-Type',
|
|
33
|
+
'Authorization',
|
|
34
|
+
'x-request-id',
|
|
35
|
+
],
|
|
36
|
+
credentials: corsConfig.credentials ?? true,
|
|
37
|
+
maxAge: corsConfig.maxAge ?? 86400,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
5
41
|
// Global validation pipe for request validation
|
|
6
42
|
app.useGlobalPipes(
|
|
7
43
|
new ValidationPipe({
|
package/src/bootstrap.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NestFactory } from '@nestjs/core';
|
|
2
|
+
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
2
3
|
import { AppModule } from './app.module';
|
|
3
4
|
import { ServerConfig } from './server/server.config';
|
|
4
5
|
import { configureApp } from './app.setup';
|
|
@@ -13,12 +14,16 @@ export type IBootstrapOptions = {
|
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
export async function bootstrap(options?: IBootstrapOptions) {
|
|
16
|
-
const app = await NestFactory.create(
|
|
17
|
-
|
|
17
|
+
const app = await NestFactory.create<NestFastifyApplication>(
|
|
18
|
+
AppModule.forRoot(options),
|
|
19
|
+
new FastifyAdapter({
|
|
20
|
+
bodyLimit: 10 * 1024, // 10kb
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
18
23
|
await configureApp(app);
|
|
19
24
|
|
|
20
25
|
const serverConfig = app.get(ServerConfig);
|
|
21
26
|
const port = serverConfig.port ?? 3000;
|
|
22
|
-
await app.listen(port);
|
|
27
|
+
await app.listen(port, '0.0.0.0');
|
|
23
28
|
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
|
24
29
|
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { ValidateNested } from 'class-validator';
|
|
2
2
|
import { Type } from 'class-transformer';
|
|
3
3
|
import { ServerConfig } from '../server/server.config';
|
|
4
|
+
import { CorsConfig } from '../cors';
|
|
5
|
+
import { HelmetConfig } from '../helmet';
|
|
4
6
|
|
|
5
7
|
export class DefaultAppConfig {
|
|
6
8
|
@ValidateNested()
|
|
7
9
|
@Type(() => ServerConfig)
|
|
8
10
|
public readonly server!: ServerConfig;
|
|
11
|
+
|
|
12
|
+
@ValidateNested()
|
|
13
|
+
@Type(() => CorsConfig)
|
|
14
|
+
public readonly cors!: CorsConfig;
|
|
15
|
+
|
|
16
|
+
@ValidateNested()
|
|
17
|
+
@Type(() => HelmetConfig)
|
|
18
|
+
public readonly helmet!: HelmetConfig;
|
|
9
19
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { CorsConfig } from './cors.config';
|
|
5
|
+
|
|
6
|
+
describe('CorsConfig', () => {
|
|
7
|
+
describe('enabled', () => {
|
|
8
|
+
it('should transform string "true" to boolean true', () => {
|
|
9
|
+
const config = plainToInstance(CorsConfig, { enabled: 'true' });
|
|
10
|
+
expect(config.enabled).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should transform string "false" to boolean false', () => {
|
|
14
|
+
const config = plainToInstance(CorsConfig, { enabled: 'false' });
|
|
15
|
+
expect(config.enabled).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should keep boolean true as true', () => {
|
|
19
|
+
const config = plainToInstance(CorsConfig, { enabled: true });
|
|
20
|
+
expect(config.enabled).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should keep boolean false as false', () => {
|
|
24
|
+
const config = plainToInstance(CorsConfig, { enabled: false });
|
|
25
|
+
expect(config.enabled).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('origins', () => {
|
|
30
|
+
it('should transform comma-separated string to array', () => {
|
|
31
|
+
const config = plainToInstance(CorsConfig, {
|
|
32
|
+
enabled: true,
|
|
33
|
+
origins: 'http://localhost:3000,http://localhost:4000',
|
|
34
|
+
});
|
|
35
|
+
expect(config.origins).toEqual(['http://localhost:3000', 'http://localhost:4000']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should trim whitespace from origins', () => {
|
|
39
|
+
const config = plainToInstance(CorsConfig, {
|
|
40
|
+
enabled: true,
|
|
41
|
+
origins: 'http://localhost:3000 , http://localhost:4000 ',
|
|
42
|
+
});
|
|
43
|
+
expect(config.origins).toEqual(['http://localhost:3000', 'http://localhost:4000']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle single origin string', () => {
|
|
47
|
+
const config = plainToInstance(CorsConfig, {
|
|
48
|
+
enabled: true,
|
|
49
|
+
origins: 'http://localhost:3000',
|
|
50
|
+
});
|
|
51
|
+
expect(config.origins).toEqual(['http://localhost:3000']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should keep array as array', () => {
|
|
55
|
+
const config = plainToInstance(CorsConfig, {
|
|
56
|
+
enabled: true,
|
|
57
|
+
origins: ['http://localhost:3000', 'http://localhost:4000'],
|
|
58
|
+
});
|
|
59
|
+
expect(config.origins).toEqual(['http://localhost:3000', 'http://localhost:4000']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('methods', () => {
|
|
64
|
+
it('should transform comma-separated string to array', () => {
|
|
65
|
+
const config = plainToInstance(CorsConfig, {
|
|
66
|
+
enabled: true,
|
|
67
|
+
methods: 'GET,POST,DELETE',
|
|
68
|
+
});
|
|
69
|
+
expect(config.methods).toEqual(['GET', 'POST', 'DELETE']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should trim whitespace from methods', () => {
|
|
73
|
+
const config = plainToInstance(CorsConfig, {
|
|
74
|
+
enabled: true,
|
|
75
|
+
methods: 'GET , POST , DELETE',
|
|
76
|
+
});
|
|
77
|
+
expect(config.methods).toEqual(['GET', 'POST', 'DELETE']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('allowedHeaders', () => {
|
|
82
|
+
it('should transform comma-separated string to array', () => {
|
|
83
|
+
const config = plainToInstance(CorsConfig, {
|
|
84
|
+
enabled: true,
|
|
85
|
+
allowedHeaders: 'Content-Type,Authorization,x-request-id',
|
|
86
|
+
});
|
|
87
|
+
expect(config.allowedHeaders).toEqual(['Content-Type', 'Authorization', 'x-request-id']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should trim whitespace from headers', () => {
|
|
91
|
+
const config = plainToInstance(CorsConfig, {
|
|
92
|
+
enabled: true,
|
|
93
|
+
allowedHeaders: 'Content-Type , Authorization , x-request-id',
|
|
94
|
+
});
|
|
95
|
+
expect(config.allowedHeaders).toEqual(['Content-Type', 'Authorization', 'x-request-id']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('credentials', () => {
|
|
100
|
+
it('should transform string "true" to boolean true', () => {
|
|
101
|
+
const config = plainToInstance(CorsConfig, { enabled: true, credentials: 'true' });
|
|
102
|
+
expect(config.credentials).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should transform string "false" to boolean false', () => {
|
|
106
|
+
const config = plainToInstance(CorsConfig, { enabled: true, credentials: 'false' });
|
|
107
|
+
expect(config.credentials).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should keep boolean values unchanged', () => {
|
|
111
|
+
const configTrue = plainToInstance(CorsConfig, { enabled: true, credentials: true });
|
|
112
|
+
const configFalse = plainToInstance(CorsConfig, { enabled: true, credentials: false });
|
|
113
|
+
expect(configTrue.credentials).toBe(true);
|
|
114
|
+
expect(configFalse.credentials).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('maxAge', () => {
|
|
119
|
+
it('should transform string to number', () => {
|
|
120
|
+
const config = plainToInstance(CorsConfig, { enabled: true, maxAge: '86400' });
|
|
121
|
+
expect(config.maxAge).toBe(86400);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should keep number unchanged', () => {
|
|
125
|
+
const config = plainToInstance(CorsConfig, { enabled: true, maxAge: 3600 });
|
|
126
|
+
expect(config.maxAge).toBe(3600);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('validation', () => {
|
|
131
|
+
it('should pass validation with valid config', async () => {
|
|
132
|
+
const config = plainToInstance(CorsConfig, {
|
|
133
|
+
enabled: true,
|
|
134
|
+
origins: ['http://localhost:3000'],
|
|
135
|
+
methods: ['GET', 'POST'],
|
|
136
|
+
allowedHeaders: ['Content-Type'],
|
|
137
|
+
credentials: true,
|
|
138
|
+
maxAge: 86400,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const errors = await validate(config);
|
|
142
|
+
expect(errors).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should pass validation with only required fields', async () => {
|
|
146
|
+
const config = plainToInstance(CorsConfig, {
|
|
147
|
+
enabled: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const errors = await validate(config);
|
|
151
|
+
expect(errors).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should fail validation when enabled is missing', async () => {
|
|
155
|
+
const config = plainToInstance(CorsConfig, {});
|
|
156
|
+
|
|
157
|
+
const errors = await validate(config);
|
|
158
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
159
|
+
expect(errors[0].property).toBe('enabled');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { IsArray, IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { TransformBoolean, TransformCommaSeparated } from '../utils';
|
|
4
|
+
|
|
5
|
+
export class CorsConfig {
|
|
6
|
+
@IsBoolean()
|
|
7
|
+
@TransformBoolean()
|
|
8
|
+
public readonly enabled!: boolean;
|
|
9
|
+
|
|
10
|
+
@IsArray()
|
|
11
|
+
@IsString({ each: true })
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@TransformCommaSeparated()
|
|
14
|
+
public readonly origins?: string[];
|
|
15
|
+
|
|
16
|
+
@IsArray()
|
|
17
|
+
@IsString({ each: true })
|
|
18
|
+
@IsOptional()
|
|
19
|
+
@TransformCommaSeparated()
|
|
20
|
+
public readonly methods?: string[];
|
|
21
|
+
|
|
22
|
+
@IsArray()
|
|
23
|
+
@IsString({ each: true })
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@TransformCommaSeparated()
|
|
26
|
+
public readonly allowedHeaders?: string[];
|
|
27
|
+
|
|
28
|
+
@IsBoolean()
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@TransformBoolean()
|
|
31
|
+
public readonly credentials?: boolean;
|
|
32
|
+
|
|
33
|
+
@IsNumber()
|
|
34
|
+
@IsOptional()
|
|
35
|
+
@Type(() => Number)
|
|
36
|
+
public readonly maxAge?: number;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './cors.config';
|
|
@@ -1,48 +1,24 @@
|
|
|
1
|
-
import { mock, Mock } from 'ts-jest-mocker';
|
|
2
1
|
import { HealthController } from './health.controller';
|
|
3
|
-
import { HealthService } from './health.service';
|
|
4
2
|
|
|
5
3
|
describe('HealthController', () => {
|
|
6
4
|
let controller: HealthController;
|
|
7
|
-
let healthService: Mock<HealthService>;
|
|
8
5
|
|
|
9
6
|
beforeEach(() => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
controller = new HealthController(healthService);
|
|
7
|
+
jest.useFakeTimers();
|
|
8
|
+
jest.setSystemTime(new Date('2024-01-20T15:30:00.000Z'));
|
|
9
|
+
controller = new HealthController();
|
|
14
10
|
});
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
status: 'ok',
|
|
20
|
-
timestamp: '2024-01-15T10:00:00.000Z',
|
|
21
|
-
uptime: 123.45,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
healthService.getHealth.mockReturnValue(mockHealth);
|
|
25
|
-
|
|
26
|
-
const result = controller.getHealth();
|
|
27
|
-
|
|
28
|
-
expect(result).toEqual(mockHealth);
|
|
29
|
-
expect(healthService.getHealth).toHaveBeenCalledTimes(1);
|
|
30
|
-
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
jest.useRealTimers();
|
|
14
|
+
});
|
|
31
15
|
|
|
16
|
+
describe('getHealth', () => {
|
|
32
17
|
it('should return the exact response from service', () => {
|
|
33
|
-
const mockHealth = {
|
|
34
|
-
status: 'ok',
|
|
35
|
-
timestamp: '2024-01-20T15:30:00.000Z',
|
|
36
|
-
uptime: 999.99,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
healthService.getHealth.mockReturnValue(mockHealth);
|
|
40
|
-
|
|
41
18
|
const result = controller.getHealth();
|
|
42
19
|
|
|
43
20
|
expect(result.status).toBe('ok');
|
|
44
21
|
expect(result.timestamp).toBe('2024-01-20T15:30:00.000Z');
|
|
45
|
-
expect(result.uptime).toBe(999.99);
|
|
46
22
|
});
|
|
47
23
|
});
|
|
48
24
|
});
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
|
|
2
|
-
import { HealthService } from './health.service';
|
|
3
2
|
|
|
4
3
|
@Controller('health')
|
|
5
4
|
export class HealthController {
|
|
6
|
-
constructor(private readonly healthService: HealthService) {}
|
|
7
|
-
|
|
8
5
|
@Get()
|
|
9
6
|
@HttpCode(HttpStatus.OK)
|
|
10
7
|
getHealth(): {
|
|
11
8
|
status: string;
|
|
12
9
|
timestamp: string;
|
|
13
|
-
uptime: number;
|
|
14
10
|
} {
|
|
15
|
-
return
|
|
11
|
+
return {
|
|
12
|
+
status: 'ok',
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
};
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Module } from '@nestjs/common';
|
|
2
2
|
import { HealthController } from './health.controller';
|
|
3
|
-
import { HealthService } from './health.service';
|
|
4
3
|
|
|
5
4
|
@Module({
|
|
6
5
|
controllers: [HealthController],
|
|
7
|
-
providers: [
|
|
8
|
-
exports: [
|
|
6
|
+
providers: [],
|
|
7
|
+
exports: [],
|
|
9
8
|
})
|
|
10
9
|
export class HealthModule {}
|
package/src/health/index.ts
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { HelmetConfig } from './helmet.config';
|
|
5
|
+
|
|
6
|
+
describe('HelmetConfig', () => {
|
|
7
|
+
describe('enabled', () => {
|
|
8
|
+
it('should transform string "true" to boolean true', () => {
|
|
9
|
+
const config = plainToInstance(HelmetConfig, { enabled: 'true' });
|
|
10
|
+
expect(config.enabled).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should transform string "false" to boolean false', () => {
|
|
14
|
+
const config = plainToInstance(HelmetConfig, { enabled: 'false' });
|
|
15
|
+
expect(config.enabled).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should keep boolean true as true', () => {
|
|
19
|
+
const config = plainToInstance(HelmetConfig, { enabled: true });
|
|
20
|
+
expect(config.enabled).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should keep boolean false as false', () => {
|
|
24
|
+
const config = plainToInstance(HelmetConfig, { enabled: false });
|
|
25
|
+
expect(config.enabled).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('contentSecurityPolicy', () => {
|
|
30
|
+
it('should transform string "true" to boolean true', () => {
|
|
31
|
+
const config = plainToInstance(HelmetConfig, {
|
|
32
|
+
enabled: true,
|
|
33
|
+
contentSecurityPolicy: 'true',
|
|
34
|
+
});
|
|
35
|
+
expect(config.contentSecurityPolicy).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should transform string "false" to boolean false', () => {
|
|
39
|
+
const config = plainToInstance(HelmetConfig, {
|
|
40
|
+
enabled: true,
|
|
41
|
+
contentSecurityPolicy: 'false',
|
|
42
|
+
});
|
|
43
|
+
expect(config.contentSecurityPolicy).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should keep boolean values unchanged', () => {
|
|
47
|
+
const configTrue = plainToInstance(HelmetConfig, {
|
|
48
|
+
enabled: true,
|
|
49
|
+
contentSecurityPolicy: true,
|
|
50
|
+
});
|
|
51
|
+
const configFalse = plainToInstance(HelmetConfig, {
|
|
52
|
+
enabled: true,
|
|
53
|
+
contentSecurityPolicy: false,
|
|
54
|
+
});
|
|
55
|
+
expect(configTrue.contentSecurityPolicy).toBe(true);
|
|
56
|
+
expect(configFalse.contentSecurityPolicy).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('crossOriginEmbedderPolicy', () => {
|
|
61
|
+
it('should transform string "true" to boolean true', () => {
|
|
62
|
+
const config = plainToInstance(HelmetConfig, {
|
|
63
|
+
enabled: true,
|
|
64
|
+
crossOriginEmbedderPolicy: 'true',
|
|
65
|
+
});
|
|
66
|
+
expect(config.crossOriginEmbedderPolicy).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should transform string "false" to boolean false', () => {
|
|
70
|
+
const config = plainToInstance(HelmetConfig, {
|
|
71
|
+
enabled: true,
|
|
72
|
+
crossOriginEmbedderPolicy: 'false',
|
|
73
|
+
});
|
|
74
|
+
expect(config.crossOriginEmbedderPolicy).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should keep boolean values unchanged', () => {
|
|
78
|
+
const configTrue = plainToInstance(HelmetConfig, {
|
|
79
|
+
enabled: true,
|
|
80
|
+
crossOriginEmbedderPolicy: true,
|
|
81
|
+
});
|
|
82
|
+
const configFalse = plainToInstance(HelmetConfig, {
|
|
83
|
+
enabled: true,
|
|
84
|
+
crossOriginEmbedderPolicy: false,
|
|
85
|
+
});
|
|
86
|
+
expect(configTrue.crossOriginEmbedderPolicy).toBe(true);
|
|
87
|
+
expect(configFalse.crossOriginEmbedderPolicy).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('hstsMaxAge', () => {
|
|
92
|
+
it('should transform string to number', () => {
|
|
93
|
+
const config = plainToInstance(HelmetConfig, { enabled: true, hstsMaxAge: '31536000' });
|
|
94
|
+
expect(config.hstsMaxAge).toBe(31536000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should keep number unchanged', () => {
|
|
98
|
+
const config = plainToInstance(HelmetConfig, { enabled: true, hstsMaxAge: 86400 });
|
|
99
|
+
expect(config.hstsMaxAge).toBe(86400);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('hstsIncludeSubDomains', () => {
|
|
104
|
+
it('should transform string "true" to boolean true', () => {
|
|
105
|
+
const config = plainToInstance(HelmetConfig, {
|
|
106
|
+
enabled: true,
|
|
107
|
+
hstsIncludeSubDomains: 'true',
|
|
108
|
+
});
|
|
109
|
+
expect(config.hstsIncludeSubDomains).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should transform string "false" to boolean false', () => {
|
|
113
|
+
const config = plainToInstance(HelmetConfig, {
|
|
114
|
+
enabled: true,
|
|
115
|
+
hstsIncludeSubDomains: 'false',
|
|
116
|
+
});
|
|
117
|
+
expect(config.hstsIncludeSubDomains).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should keep boolean values unchanged', () => {
|
|
121
|
+
const configTrue = plainToInstance(HelmetConfig, {
|
|
122
|
+
enabled: true,
|
|
123
|
+
hstsIncludeSubDomains: true,
|
|
124
|
+
});
|
|
125
|
+
const configFalse = plainToInstance(HelmetConfig, {
|
|
126
|
+
enabled: true,
|
|
127
|
+
hstsIncludeSubDomains: false,
|
|
128
|
+
});
|
|
129
|
+
expect(configTrue.hstsIncludeSubDomains).toBe(true);
|
|
130
|
+
expect(configFalse.hstsIncludeSubDomains).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('hstsPreload', () => {
|
|
135
|
+
it('should transform string "true" to boolean true', () => {
|
|
136
|
+
const config = plainToInstance(HelmetConfig, {
|
|
137
|
+
enabled: true,
|
|
138
|
+
hstsPreload: 'true',
|
|
139
|
+
});
|
|
140
|
+
expect(config.hstsPreload).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should transform string "false" to boolean false', () => {
|
|
144
|
+
const config = plainToInstance(HelmetConfig, {
|
|
145
|
+
enabled: true,
|
|
146
|
+
hstsPreload: 'false',
|
|
147
|
+
});
|
|
148
|
+
expect(config.hstsPreload).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should keep boolean values unchanged', () => {
|
|
152
|
+
const configTrue = plainToInstance(HelmetConfig, {
|
|
153
|
+
enabled: true,
|
|
154
|
+
hstsPreload: true,
|
|
155
|
+
});
|
|
156
|
+
const configFalse = plainToInstance(HelmetConfig, {
|
|
157
|
+
enabled: true,
|
|
158
|
+
hstsPreload: false,
|
|
159
|
+
});
|
|
160
|
+
expect(configTrue.hstsPreload).toBe(true);
|
|
161
|
+
expect(configFalse.hstsPreload).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('validation', () => {
|
|
166
|
+
it('should pass validation with valid config', async () => {
|
|
167
|
+
const config = plainToInstance(HelmetConfig, {
|
|
168
|
+
enabled: true,
|
|
169
|
+
contentSecurityPolicy: true,
|
|
170
|
+
crossOriginEmbedderPolicy: true,
|
|
171
|
+
hstsMaxAge: 31536000,
|
|
172
|
+
hstsIncludeSubDomains: true,
|
|
173
|
+
hstsPreload: false,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const errors = await validate(config);
|
|
177
|
+
expect(errors).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should pass validation with only required fields', async () => {
|
|
181
|
+
const config = plainToInstance(HelmetConfig, {
|
|
182
|
+
enabled: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const errors = await validate(config);
|
|
186
|
+
expect(errors).toHaveLength(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should fail validation when enabled is missing', async () => {
|
|
190
|
+
const config = plainToInstance(HelmetConfig, {});
|
|
191
|
+
|
|
192
|
+
const errors = await validate(config);
|
|
193
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
194
|
+
expect(errors[0].property).toBe('enabled');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { IsBoolean, IsNumber, IsOptional } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { TransformBoolean } from '../utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @see https://helmetjs.github.io/
|
|
7
|
+
*/
|
|
8
|
+
export class HelmetConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Whether to enable Helmet middleware
|
|
11
|
+
*/
|
|
12
|
+
@IsBoolean()
|
|
13
|
+
@TransformBoolean()
|
|
14
|
+
public readonly enabled!: boolean;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether to enable Content-Security-Policy header.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
@IsBoolean()
|
|
21
|
+
@IsOptional()
|
|
22
|
+
@TransformBoolean()
|
|
23
|
+
public readonly contentSecurityPolicy?: boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether to enable Cross-Origin-Embedder-Policy header.
|
|
27
|
+
* @default true
|
|
28
|
+
*/
|
|
29
|
+
@IsBoolean()
|
|
30
|
+
@IsOptional()
|
|
31
|
+
@TransformBoolean()
|
|
32
|
+
public readonly crossOriginEmbedderPolicy?: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* HSTS max-age in seconds.
|
|
36
|
+
* @default 31536000 (1 year)
|
|
37
|
+
*/
|
|
38
|
+
@IsNumber()
|
|
39
|
+
@IsOptional()
|
|
40
|
+
@Type(() => Number)
|
|
41
|
+
public readonly hstsMaxAge?: number;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether to include subdomains in HSTS header.
|
|
45
|
+
* @default true
|
|
46
|
+
*/
|
|
47
|
+
@IsBoolean()
|
|
48
|
+
@IsOptional()
|
|
49
|
+
@TransformBoolean()
|
|
50
|
+
public readonly hstsIncludeSubDomains?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether to add HSTS preload directive.
|
|
54
|
+
* @default false
|
|
55
|
+
*/
|
|
56
|
+
@IsBoolean()
|
|
57
|
+
@IsOptional()
|
|
58
|
+
@TransformBoolean()
|
|
59
|
+
public readonly hstsPreload?: boolean;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './helmet.config';
|