@dismissible/nestjs-api 0.0.2-canary.c91edbc.0 → 0.0.2-canary.d2f56d7.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.
- package/config/.env.yaml +19 -2
- package/jest.config.ts +23 -1
- package/jest.e2e-config.ts +3 -1
- package/package.json +13 -7
- package/project.json +6 -3
- package/scripts/performance-test.ts +2 -2
- package/src/app.module.ts +13 -1
- package/src/app.setup.ts +7 -5
- package/src/config/app.config.spec.ts +118 -0
- package/src/config/app.config.ts +5 -0
- package/src/config/default-app.config.spec.ts +74 -0
- package/src/config/default-app.config.ts +5 -0
- package/src/cors/cors.config.ts +1 -1
- package/src/helmet/helmet.config.ts +1 -1
- package/src/server/server.config.spec.ts +65 -0
- package/src/swagger/swagger.config.spec.ts +113 -0
- package/src/swagger/swagger.config.ts +2 -10
- package/src/swagger/swagger.factory.spec.ts +126 -0
- package/src/validation/index.ts +1 -0
- package/src/validation/validation.config.ts +47 -0
- package/test/config/.env.yaml +23 -0
- package/test/config-jwt-auth/.env.yaml +26 -0
- package/test/dismiss.e2e-spec.ts +64 -0
- package/test/full-cycle.e2e-spec.ts +57 -0
- package/test/get-or-create.e2e-spec.ts +51 -0
- package/test/jwt-auth.e2e-spec.ts +353 -0
- package/test/restore.e2e-spec.ts +63 -0
- package/tsconfig.e2e.json +12 -0
- package/tsconfig.spec.json +12 -0
- package/src/app.e2e-spec.ts +0 -221
- package/src/utils/index.ts +0 -2
- package/src/utils/transform-boolean.decorator.spec.ts +0 -47
- package/src/utils/transform-boolean.decorator.ts +0 -19
- package/src/utils/transform-comma-separated.decorator.ts +0 -16
package/config/.env.yaml
CHANGED
|
@@ -14,11 +14,28 @@ helmet:
|
|
|
14
14
|
|
|
15
15
|
cors:
|
|
16
16
|
enabled: ${DISMISSIBLE_CORS_ENABLED:-true}
|
|
17
|
-
origins: ${DISMISSIBLE_CORS_ORIGINS:-http://localhost:
|
|
17
|
+
origins: ${DISMISSIBLE_CORS_ORIGINS:-http://localhost:3001,http://localhost:5173}
|
|
18
18
|
methods: ${DISMISSIBLE_CORS_METHODS:-GET,POST,DELETE,OPTIONS}
|
|
19
19
|
allowedHeaders: ${DISMISSIBLE_CORS_ALLOWED_HEADERS:-Content-Type,Authorization,x-request-id}
|
|
20
20
|
credentials: ${DISMISSIBLE_CORS_CREDENTIALS:-true}
|
|
21
21
|
maxAge: ${DISMISSIBLE_CORS_MAX_AGE:-86400}
|
|
22
22
|
|
|
23
23
|
db:
|
|
24
|
-
connectionString: ${DISMISSIBLE_POSTGRES_STORAGE_CONNECTION_STRING:-
|
|
24
|
+
connectionString: ${DISMISSIBLE_POSTGRES_STORAGE_CONNECTION_STRING:-postgresql://postgres:postgres@localhost:5432/dismissible}
|
|
25
|
+
|
|
26
|
+
jwtAuth:
|
|
27
|
+
enabled: ${DISMISSIBLE_JWT_AUTH_ENABLED:-false}
|
|
28
|
+
wellKnownUrl: ${DISMISSIBLE_JWT_AUTH_WELL_KNOWN_URL:-}
|
|
29
|
+
issuer: ${DISMISSIBLE_JWT_AUTH_ISSUER:-}
|
|
30
|
+
audience: ${DISMISSIBLE_JWT_AUTH_AUDIENCE:-}
|
|
31
|
+
algorithms:
|
|
32
|
+
- ${DISMISSIBLE_JWT_AUTH_ALGORITHMS:-RS256}
|
|
33
|
+
jwksCacheDuration: ${DISMISSIBLE_JWT_AUTH_JWKS_CACHE_DURATION:-600000}
|
|
34
|
+
requestTimeout: ${DISMISSIBLE_JWT_AUTH_REQUEST_TIMEOUT:-30000}
|
|
35
|
+
priority: ${DISMISSIBLE_JWT_AUTH_PRIORITY:--100}
|
|
36
|
+
|
|
37
|
+
validation:
|
|
38
|
+
disableErrorMessages: ${DISMISSIBLE_VALIDATION_DISABLE_ERROR_MESSAGES:-true}
|
|
39
|
+
whitelist: ${DISMISSIBLE_VALIDATION_WHITELIST:-true}
|
|
40
|
+
forbidNonWhitelisted: ${DISMISSIBLE_VALIDATION_FORBID_NON_WHITELISTED:-true}
|
|
41
|
+
transform: ${DISMISSIBLE_VALIDATION_TRANSFORM:-true}
|
package/jest.config.ts
CHANGED
|
@@ -3,11 +3,33 @@ export default {
|
|
|
3
3
|
preset: '../jest.preset.js',
|
|
4
4
|
testEnvironment: 'node',
|
|
5
5
|
transform: {
|
|
6
|
-
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
|
7
7
|
},
|
|
8
8
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
9
|
coverageDirectory: '../coverage/api',
|
|
10
10
|
testMatch: ['**/*.spec.ts'],
|
|
11
11
|
testPathIgnorePatterns: ['\\.e2e-spec\\.ts$'],
|
|
12
12
|
transformIgnorePatterns: ['node_modules/(?!(nest-typed-config|uuid)/)'],
|
|
13
|
+
collectCoverageFrom: [
|
|
14
|
+
'src/**/*.ts',
|
|
15
|
+
'!src/**/*.spec.ts',
|
|
16
|
+
'!src/**/*.e2e-spec.ts',
|
|
17
|
+
'!src/**/*.interface.ts',
|
|
18
|
+
'!src/**/*.dto.ts',
|
|
19
|
+
'!src/**/*.enum.ts',
|
|
20
|
+
'!src/**/index.ts',
|
|
21
|
+
'!src/**/*.module.ts',
|
|
22
|
+
'!src/main.ts',
|
|
23
|
+
'!src/bootstrap.ts',
|
|
24
|
+
'!src/app.setup.ts',
|
|
25
|
+
'!src/app-test.factory.ts',
|
|
26
|
+
],
|
|
27
|
+
coverageThreshold: {
|
|
28
|
+
global: {
|
|
29
|
+
branches: 80,
|
|
30
|
+
functions: 80,
|
|
31
|
+
lines: 80,
|
|
32
|
+
statements: 80,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
13
35
|
};
|
package/jest.e2e-config.ts
CHANGED
|
@@ -3,10 +3,12 @@ export default {
|
|
|
3
3
|
preset: '../jest.preset.js',
|
|
4
4
|
testEnvironment: 'node',
|
|
5
5
|
transform: {
|
|
6
|
-
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.e2e.json' }],
|
|
7
7
|
},
|
|
8
8
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
9
|
coverageDirectory: '../coverage/api-e2e',
|
|
10
10
|
testMatch: ['**/*.e2e-spec.ts'],
|
|
11
11
|
transformIgnorePatterns: ['node_modules/(?!(nest-typed-config|uuid)/)'],
|
|
12
|
+
// Run tests sequentially to avoid database conflicts
|
|
13
|
+
maxWorkers: 1,
|
|
12
14
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dismissible/nestjs-api",
|
|
3
|
-
"version": "0.0.2-canary.
|
|
3
|
+
"version": "0.0.2-canary.d2f56d7.0",
|
|
4
4
|
"description": "Dismissible API application",
|
|
5
5
|
"main": "./src/main.js",
|
|
6
6
|
"types": "./src/main.d.ts",
|
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
"dismissible-api": "./src/main.js"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"start": "node src/main.js"
|
|
18
|
+
"start": "node src/main.js",
|
|
19
|
+
"prisma:generate": "npx dismissible-prisma generate",
|
|
20
|
+
"prisma:migrate:deploy": "npx dismissible-prisma migrate deploy",
|
|
21
|
+
"prisma:migrate:dev": "npx dismissible-prisma migrate dev",
|
|
22
|
+
"prisma:db:push": "npx dismissible-prisma db push",
|
|
23
|
+
"prisma:studio": "npx dismissible-prisma studio"
|
|
19
24
|
},
|
|
20
25
|
"dependencies": {
|
|
21
|
-
"@dismissible/nestjs-dismissible": "^0.0.2-canary.
|
|
22
|
-
"@dismissible/nestjs-dismissible-item": "^0.0.2-canary.
|
|
23
|
-
"@dismissible/nestjs-
|
|
24
|
-
"@dismissible/nestjs-
|
|
25
|
-
"@dismissible/nestjs-
|
|
26
|
+
"@dismissible/nestjs-dismissible": "^0.0.2-canary.d2f56d7.0",
|
|
27
|
+
"@dismissible/nestjs-dismissible-item": "^0.0.2-canary.d2f56d7.0",
|
|
28
|
+
"@dismissible/nestjs-jwt-auth-hook": "^0.0.1",
|
|
29
|
+
"@dismissible/nestjs-storage": "^0.0.2-canary.d2f56d7.0",
|
|
30
|
+
"@dismissible/nestjs-postgres-storage": "^0.0.2-canary.d2f56d7.0",
|
|
31
|
+
"@dismissible/nestjs-logger": "^0.0.2-canary.d2f56d7.0",
|
|
26
32
|
"@nestjs/common": "^11.1.9",
|
|
27
33
|
"@nestjs/core": "^11.1.9",
|
|
28
34
|
"@nestjs/platform-fastify": "^11.1.9",
|
package/project.json
CHANGED
|
@@ -65,19 +65,22 @@
|
|
|
65
65
|
"prisma:generate": {
|
|
66
66
|
"executor": "nx:run-commands",
|
|
67
67
|
"options": {
|
|
68
|
-
"command": "
|
|
68
|
+
"command": "npm run prisma:generate",
|
|
69
|
+
"cwd": "api"
|
|
69
70
|
}
|
|
70
71
|
},
|
|
71
72
|
"prisma:migrate": {
|
|
72
73
|
"executor": "nx:run-commands",
|
|
73
74
|
"options": {
|
|
74
|
-
"command": "
|
|
75
|
+
"command": "npm run prisma:migrate:dev",
|
|
76
|
+
"cwd": "api"
|
|
75
77
|
}
|
|
76
78
|
},
|
|
77
79
|
"prisma:push": {
|
|
78
80
|
"executor": "nx:run-commands",
|
|
79
81
|
"options": {
|
|
80
|
-
"command": "
|
|
82
|
+
"command": "npm run prisma:db:push",
|
|
83
|
+
"cwd": "api"
|
|
81
84
|
}
|
|
82
85
|
},
|
|
83
86
|
"npm-publish": {
|
|
@@ -673,7 +673,7 @@ async function runCreatePhase(
|
|
|
673
673
|
for (let i = 0; i < config.createCount; i++) {
|
|
674
674
|
const userId = userIds[i % userIds.length];
|
|
675
675
|
const itemId = itemIds[i];
|
|
676
|
-
paths.push(`/v1/
|
|
676
|
+
paths.push(`/v1/users/${userId}/items/${itemId}`);
|
|
677
677
|
}
|
|
678
678
|
|
|
679
679
|
const instance = autocannon(
|
|
@@ -741,7 +741,7 @@ async function runGetPhase(
|
|
|
741
741
|
for (let i = 0; i < itemsToGet; i++) {
|
|
742
742
|
const userId = userIds[i % userIds.length];
|
|
743
743
|
const itemId = itemIds[i];
|
|
744
|
-
paths.push(`/v1/
|
|
744
|
+
paths.push(`/v1/users/${userId}/items/${itemId}`);
|
|
745
745
|
}
|
|
746
746
|
|
|
747
747
|
const instance = autocannon(
|
package/src/app.module.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
1
|
+
import { DynamicModule, Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
2
2
|
import { HealthModule } from './health';
|
|
3
3
|
import { ConfigModule } from './config';
|
|
4
4
|
import { AppConfig } from './config/app.config';
|
|
@@ -7,11 +7,17 @@ import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
|
7
7
|
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
8
|
import { DefaultAppConfig } from './config/default-app.config';
|
|
9
9
|
import { PostgresStorageConfig, PostgresStorageModule } from '@dismissible/nestjs-postgres-storage';
|
|
10
|
+
import {
|
|
11
|
+
JwtAuthHookModule,
|
|
12
|
+
JwtAuthHook,
|
|
13
|
+
JwtAuthHookConfig,
|
|
14
|
+
} from '@dismissible/nestjs-jwt-auth-hook';
|
|
10
15
|
|
|
11
16
|
export type AppModuleOptions = {
|
|
12
17
|
configPath?: string;
|
|
13
18
|
schema?: new () => DefaultAppConfig;
|
|
14
19
|
logger?: Type<IDismissibleLogger>;
|
|
20
|
+
imports?: DynamicModule[];
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
@Module({})
|
|
@@ -31,8 +37,14 @@ export class AppModule {
|
|
|
31
37
|
schema: options?.schema ?? AppConfig,
|
|
32
38
|
}),
|
|
33
39
|
HealthModule,
|
|
40
|
+
...(options?.imports ?? []),
|
|
41
|
+
JwtAuthHookModule.forRootAsync({
|
|
42
|
+
useFactory: (config: JwtAuthHookConfig) => config,
|
|
43
|
+
inject: [JwtAuthHookConfig],
|
|
44
|
+
}),
|
|
34
45
|
DismissibleModule.forRoot({
|
|
35
46
|
logger: options?.logger,
|
|
47
|
+
hooks: [JwtAuthHook],
|
|
36
48
|
storage: PostgresStorageModule.forRootAsync({
|
|
37
49
|
useFactory(config: PostgresStorageConfig) {
|
|
38
50
|
return {
|
package/src/app.setup.ts
CHANGED
|
@@ -4,6 +4,7 @@ import fastifyHelmet from '@fastify/helmet';
|
|
|
4
4
|
import { SwaggerConfig, configureAppWithSwagger } from './swagger';
|
|
5
5
|
import { CorsConfig } from './cors';
|
|
6
6
|
import { HelmetConfig } from './helmet';
|
|
7
|
+
import { ValidationConfig } from './validation';
|
|
7
8
|
|
|
8
9
|
export async function configureApp(app: INestApplication): Promise<void> {
|
|
9
10
|
const fastifyApp = app as NestFastifyApplication;
|
|
@@ -26,7 +27,7 @@ export async function configureApp(app: INestApplication): Promise<void> {
|
|
|
26
27
|
const corsConfig = app.get(CorsConfig);
|
|
27
28
|
if (corsConfig.enabled) {
|
|
28
29
|
app.enableCors({
|
|
29
|
-
origin: corsConfig.origins ?? ['http://localhost:
|
|
30
|
+
origin: corsConfig.origins ?? ['http://localhost:3001', 'http://localhost:5173'],
|
|
30
31
|
methods: corsConfig.methods ?? ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
31
32
|
allowedHeaders: corsConfig.allowedHeaders ?? [
|
|
32
33
|
'Content-Type',
|
|
@@ -39,12 +40,13 @@ export async function configureApp(app: INestApplication): Promise<void> {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// Global validation pipe for request validation
|
|
43
|
+
const validationConfig = app.get(ValidationConfig);
|
|
42
44
|
app.useGlobalPipes(
|
|
43
45
|
new ValidationPipe({
|
|
44
|
-
whitelist: true,
|
|
45
|
-
forbidNonWhitelisted: true,
|
|
46
|
-
transform: true,
|
|
47
|
-
disableErrorMessages:
|
|
46
|
+
whitelist: validationConfig.whitelist ?? true,
|
|
47
|
+
forbidNonWhitelisted: validationConfig.forbidNonWhitelisted ?? true,
|
|
48
|
+
transform: validationConfig.transform ?? true,
|
|
49
|
+
disableErrorMessages: validationConfig.disableErrorMessages ?? true,
|
|
48
50
|
}),
|
|
49
51
|
);
|
|
50
52
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { AppConfig } from './app.config';
|
|
5
|
+
import { SwaggerConfig } from '../swagger/swagger.config';
|
|
6
|
+
import { PostgresStorageConfig } from '@dismissible/nestjs-postgres-storage';
|
|
7
|
+
import { JwtAuthHookConfig } from '@dismissible/nestjs-jwt-auth-hook';
|
|
8
|
+
|
|
9
|
+
describe('AppConfig', () => {
|
|
10
|
+
describe('transformation', () => {
|
|
11
|
+
it('should transform all nested configs correctly', () => {
|
|
12
|
+
const config = plainToInstance(AppConfig, {
|
|
13
|
+
server: { port: 3001 },
|
|
14
|
+
cors: { enabled: true },
|
|
15
|
+
helmet: { enabled: true },
|
|
16
|
+
swagger: { enabled: true },
|
|
17
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
18
|
+
jwtAuth: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(config.swagger).toBeInstanceOf(SwaggerConfig);
|
|
25
|
+
expect(config.swagger.enabled).toBe(true);
|
|
26
|
+
expect(config.db).toBeInstanceOf(PostgresStorageConfig);
|
|
27
|
+
expect(config.db.connectionString).toBe('postgresql://localhost:5432/test');
|
|
28
|
+
expect(config.jwtAuth).toBeInstanceOf(JwtAuthHookConfig);
|
|
29
|
+
expect(config.jwtAuth.enabled).toBe(true);
|
|
30
|
+
expect(config.jwtAuth.wellKnownUrl).toBe(
|
|
31
|
+
'https://auth.example.com/.well-known/openid-configuration',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('validation', () => {
|
|
37
|
+
it('should pass validation with all required nested configs', async () => {
|
|
38
|
+
const config = plainToInstance(AppConfig, {
|
|
39
|
+
server: { port: 3001 },
|
|
40
|
+
cors: { enabled: true },
|
|
41
|
+
helmet: { enabled: true },
|
|
42
|
+
swagger: { enabled: true },
|
|
43
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
44
|
+
jwtAuth: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const errors = await validate(config);
|
|
50
|
+
expect(errors).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should fail validation when swagger config has invalid nested property', async () => {
|
|
54
|
+
const config = plainToInstance(AppConfig, {
|
|
55
|
+
server: { port: 3001 },
|
|
56
|
+
cors: { enabled: true },
|
|
57
|
+
helmet: { enabled: true },
|
|
58
|
+
swagger: { enabled: true, path: 123 },
|
|
59
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
60
|
+
jwtAuth: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const errors = await validate(config);
|
|
66
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
67
|
+
const swaggerError = errors.find((e) => e.property === 'swagger');
|
|
68
|
+
expect(swaggerError).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should fail validation when db config is invalid', async () => {
|
|
72
|
+
const config = plainToInstance(AppConfig, {
|
|
73
|
+
server: { port: 3001 },
|
|
74
|
+
cors: { enabled: true },
|
|
75
|
+
helmet: { enabled: true },
|
|
76
|
+
swagger: { enabled: true },
|
|
77
|
+
db: { connectionString: 123 },
|
|
78
|
+
jwtAuth: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const errors = await validate(config);
|
|
84
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
85
|
+
const dbError = errors.find((e) => e.property === 'db');
|
|
86
|
+
expect(dbError).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should fail validation when jwtAuth config is invalid', async () => {
|
|
90
|
+
const config = plainToInstance(AppConfig, {
|
|
91
|
+
server: { port: 3001 },
|
|
92
|
+
cors: { enabled: true },
|
|
93
|
+
helmet: { enabled: true },
|
|
94
|
+
swagger: { enabled: true },
|
|
95
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
96
|
+
jwtAuth: { enabled: true, wellKnownUrl: 'not-a-valid-url' },
|
|
97
|
+
});
|
|
98
|
+
const errors = await validate(config);
|
|
99
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
100
|
+
const jwtAuthError = errors.find((e) => e.property === 'jwtAuth');
|
|
101
|
+
expect(jwtAuthError).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should fail validation when jwtAuth enabled is false but wellKnownUrl is required', async () => {
|
|
105
|
+
const config = plainToInstance(AppConfig, {
|
|
106
|
+
server: { port: 3001 },
|
|
107
|
+
cors: { enabled: true },
|
|
108
|
+
helmet: { enabled: true },
|
|
109
|
+
swagger: { enabled: true },
|
|
110
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
111
|
+
jwtAuth: { enabled: false },
|
|
112
|
+
});
|
|
113
|
+
const errors = await validate(config);
|
|
114
|
+
// When enabled is false, wellKnownUrl is not required due to ValidateIf
|
|
115
|
+
expect(errors).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/config/app.config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
|
|
3
3
|
import { SwaggerConfig } from '../swagger';
|
|
4
4
|
import { DefaultAppConfig } from './default-app.config';
|
|
5
5
|
import { PostgresStorageConfig } from '@dismissible/nestjs-postgres-storage';
|
|
6
|
+
import { JwtAuthHookConfig } from '@dismissible/nestjs-jwt-auth-hook';
|
|
6
7
|
|
|
7
8
|
export class AppConfig extends DefaultAppConfig {
|
|
8
9
|
@ValidateNested()
|
|
@@ -12,4 +13,8 @@ export class AppConfig extends DefaultAppConfig {
|
|
|
12
13
|
@ValidateNested()
|
|
13
14
|
@Type(() => PostgresStorageConfig)
|
|
14
15
|
public readonly db!: PostgresStorageConfig;
|
|
16
|
+
|
|
17
|
+
@ValidateNested()
|
|
18
|
+
@Type(() => JwtAuthHookConfig)
|
|
19
|
+
public readonly jwtAuth!: JwtAuthHookConfig;
|
|
15
20
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { DefaultAppConfig } from './default-app.config';
|
|
5
|
+
import { ServerConfig } from '../server/server.config';
|
|
6
|
+
import { CorsConfig } from '../cors/cors.config';
|
|
7
|
+
import { HelmetConfig } from '../helmet/helmet.config';
|
|
8
|
+
|
|
9
|
+
describe('DefaultAppConfig', () => {
|
|
10
|
+
describe('transformation', () => {
|
|
11
|
+
it('should transform all nested configs correctly', () => {
|
|
12
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
13
|
+
server: { port: 3001 },
|
|
14
|
+
cors: { enabled: true },
|
|
15
|
+
helmet: { enabled: true },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(config.server).toBeInstanceOf(ServerConfig);
|
|
19
|
+
expect(config.server.port).toBe(3001);
|
|
20
|
+
expect(config.cors).toBeInstanceOf(CorsConfig);
|
|
21
|
+
expect(config.cors.enabled).toBe(true);
|
|
22
|
+
expect(config.helmet).toBeInstanceOf(HelmetConfig);
|
|
23
|
+
expect(config.helmet.enabled).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('validation', () => {
|
|
28
|
+
it('should pass validation with all required nested configs', async () => {
|
|
29
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
30
|
+
server: { port: 3001 },
|
|
31
|
+
cors: { enabled: true },
|
|
32
|
+
helmet: { enabled: true },
|
|
33
|
+
});
|
|
34
|
+
const errors = await validate(config);
|
|
35
|
+
expect(errors).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should fail validation when server config is invalid', async () => {
|
|
39
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
40
|
+
server: { port: 'not-a-number' },
|
|
41
|
+
cors: { enabled: true },
|
|
42
|
+
helmet: { enabled: true },
|
|
43
|
+
});
|
|
44
|
+
const errors = await validate(config);
|
|
45
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
46
|
+
const serverError = errors.find((e) => e.property === 'server');
|
|
47
|
+
expect(serverError).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should fail validation when cors config has invalid nested property', async () => {
|
|
51
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
52
|
+
server: { port: 3001 },
|
|
53
|
+
cors: { enabled: true, maxAge: 'not-a-number' },
|
|
54
|
+
helmet: { enabled: true },
|
|
55
|
+
});
|
|
56
|
+
const errors = await validate(config);
|
|
57
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
58
|
+
const corsError = errors.find((e) => e.property === 'cors');
|
|
59
|
+
expect(corsError).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should fail validation when helmet config has invalid nested property', async () => {
|
|
63
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
64
|
+
server: { port: 3001 },
|
|
65
|
+
cors: { enabled: true },
|
|
66
|
+
helmet: { enabled: true, hstsMaxAge: 'not-a-number' },
|
|
67
|
+
});
|
|
68
|
+
const errors = await validate(config);
|
|
69
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
70
|
+
const helmetError = errors.find((e) => e.property === 'helmet');
|
|
71
|
+
expect(helmetError).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
|
|
3
3
|
import { ServerConfig } from '../server/server.config';
|
|
4
4
|
import { CorsConfig } from '../cors';
|
|
5
5
|
import { HelmetConfig } from '../helmet';
|
|
6
|
+
import { ValidationConfig } from '../validation';
|
|
6
7
|
|
|
7
8
|
export class DefaultAppConfig {
|
|
8
9
|
@ValidateNested()
|
|
@@ -16,4 +17,8 @@ export class DefaultAppConfig {
|
|
|
16
17
|
@ValidateNested()
|
|
17
18
|
@Type(() => HelmetConfig)
|
|
18
19
|
public readonly helmet!: HelmetConfig;
|
|
20
|
+
|
|
21
|
+
@ValidateNested()
|
|
22
|
+
@Type(() => ValidationConfig)
|
|
23
|
+
public readonly validation!: ValidationConfig;
|
|
19
24
|
}
|
package/src/cors/cors.config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IsArray, IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
|
2
2
|
import { Type } from 'class-transformer';
|
|
3
|
-
import { TransformBoolean, TransformCommaSeparated } from '
|
|
3
|
+
import { TransformBoolean, TransformCommaSeparated } from '@dismissible/nestjs-validation';
|
|
4
4
|
|
|
5
5
|
export class CorsConfig {
|
|
6
6
|
@IsBoolean()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IsBoolean, IsNumber, IsOptional } from 'class-validator';
|
|
2
2
|
import { Type } from 'class-transformer';
|
|
3
|
-
import { TransformBoolean } from '
|
|
3
|
+
import { TransformBoolean } from '@dismissible/nestjs-validation';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @see https://helmetjs.github.io/
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { ServerConfig } from './server.config';
|
|
5
|
+
|
|
6
|
+
describe('ServerConfig', () => {
|
|
7
|
+
describe('port', () => {
|
|
8
|
+
it('should transform string to number', () => {
|
|
9
|
+
const config = plainToInstance(ServerConfig, { port: '3001' });
|
|
10
|
+
expect(config.port).toBe(3001);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should keep number unchanged', () => {
|
|
14
|
+
const config = plainToInstance(ServerConfig, { port: 3001 });
|
|
15
|
+
expect(config.port).toBe(3001);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should handle different port numbers', () => {
|
|
19
|
+
const config = plainToInstance(ServerConfig, { port: '8080' });
|
|
20
|
+
expect(config.port).toBe(8080);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('validation', () => {
|
|
25
|
+
it('should pass validation with valid port number', async () => {
|
|
26
|
+
const config = plainToInstance(ServerConfig, { port: 3001 });
|
|
27
|
+
const errors = await validate(config);
|
|
28
|
+
expect(errors).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should pass validation with string port that transforms to number', async () => {
|
|
32
|
+
const config = plainToInstance(ServerConfig, { port: '3001' });
|
|
33
|
+
const errors = await validate(config);
|
|
34
|
+
expect(errors).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should fail validation when port is missing', async () => {
|
|
38
|
+
const config = plainToInstance(ServerConfig, {});
|
|
39
|
+
const errors = await validate(config);
|
|
40
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
41
|
+
expect(errors[0].property).toBe('port');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should fail validation when port is not a number', async () => {
|
|
45
|
+
const config = plainToInstance(ServerConfig, { port: 'not-a-number' });
|
|
46
|
+
const errors = await validate(config);
|
|
47
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
48
|
+
expect(errors[0].property).toBe('port');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should fail validation when port is null', async () => {
|
|
52
|
+
const config = plainToInstance(ServerConfig, { port: null });
|
|
53
|
+
const errors = await validate(config);
|
|
54
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
55
|
+
expect(errors[0].property).toBe('port');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should fail validation when port is undefined', async () => {
|
|
59
|
+
const config = plainToInstance(ServerConfig, { port: undefined });
|
|
60
|
+
const errors = await validate(config);
|
|
61
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
62
|
+
expect(errors[0].property).toBe('port');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { SwaggerConfig } from './swagger.config';
|
|
5
|
+
|
|
6
|
+
describe('SwaggerConfig', () => {
|
|
7
|
+
describe('enabled', () => {
|
|
8
|
+
it('should transform string "true" to boolean true', () => {
|
|
9
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'true' });
|
|
10
|
+
expect(config.enabled).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should transform string "false" to boolean false', () => {
|
|
14
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'false' });
|
|
15
|
+
expect(config.enabled).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should keep boolean true as true', () => {
|
|
19
|
+
const config = plainToInstance(SwaggerConfig, { enabled: true });
|
|
20
|
+
expect(config.enabled).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should keep boolean false as false', () => {
|
|
24
|
+
const config = plainToInstance(SwaggerConfig, { enabled: false });
|
|
25
|
+
expect(config.enabled).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should transform other string values to false', () => {
|
|
29
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'yes' });
|
|
30
|
+
expect(config.enabled).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle case-insensitive "true"', () => {
|
|
34
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'TRUE' });
|
|
35
|
+
expect(config.enabled).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should handle case-insensitive "True"', () => {
|
|
39
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'True' });
|
|
40
|
+
expect(config.enabled).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('path', () => {
|
|
45
|
+
it('should accept string path', () => {
|
|
46
|
+
const config = plainToInstance(SwaggerConfig, {
|
|
47
|
+
enabled: true,
|
|
48
|
+
path: 'api-docs',
|
|
49
|
+
});
|
|
50
|
+
expect(config.path).toBe('api-docs');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should allow path to be optional', () => {
|
|
54
|
+
const config = plainToInstance(SwaggerConfig, { enabled: true });
|
|
55
|
+
expect(config.path).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('validation', () => {
|
|
60
|
+
it('should pass validation with valid config', async () => {
|
|
61
|
+
const config = plainToInstance(SwaggerConfig, {
|
|
62
|
+
enabled: true,
|
|
63
|
+
path: 'docs',
|
|
64
|
+
});
|
|
65
|
+
const errors = await validate(config);
|
|
66
|
+
expect(errors).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should pass validation with only required fields', async () => {
|
|
70
|
+
const config = plainToInstance(SwaggerConfig, { enabled: true });
|
|
71
|
+
const errors = await validate(config);
|
|
72
|
+
expect(errors).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should pass validation when enabled is transformed from string', async () => {
|
|
76
|
+
const config = plainToInstance(SwaggerConfig, { enabled: 'true' });
|
|
77
|
+
const errors = await validate(config);
|
|
78
|
+
expect(errors).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should fail validation when enabled is missing', async () => {
|
|
82
|
+
const config = plainToInstance(SwaggerConfig, {});
|
|
83
|
+
const errors = await validate(config);
|
|
84
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
85
|
+
expect(errors[0].property).toBe('enabled');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should fail validation when enabled is null', async () => {
|
|
89
|
+
const config = plainToInstance(SwaggerConfig, { enabled: null });
|
|
90
|
+
const errors = await validate(config);
|
|
91
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
92
|
+
expect(errors[0].property).toBe('enabled');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should fail validation when enabled is undefined', async () => {
|
|
96
|
+
const config = plainToInstance(SwaggerConfig, { enabled: undefined });
|
|
97
|
+
const errors = await validate(config);
|
|
98
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
99
|
+
expect(errors[0].property).toBe('enabled');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should fail validation when path is not a string', async () => {
|
|
103
|
+
const config = plainToInstance(SwaggerConfig, {
|
|
104
|
+
enabled: true,
|
|
105
|
+
path: 123,
|
|
106
|
+
});
|
|
107
|
+
const errors = await validate(config);
|
|
108
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
109
|
+
const pathError = errors.find((e) => e.property === 'path');
|
|
110
|
+
expect(pathError).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|