@bi-analytics/arness-back 0.0.0-stage → 0.3.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/CHANGELOG.md +85 -0
- package/README.md +156 -2
- package/bin/arness.mjs +710 -0
- package/harness/AGENTS.md +238 -0
- package/harness/agents/implementador.md +42 -0
- package/harness/agents/lider.md +72 -0
- package/harness/agents/revisor.md +62 -0
- package/harness/decisiones/README.md +17 -0
- package/harness/decisiones/_plantilla.md +24 -0
- package/harness/init.sh +743 -0
- package/harness/playbooks/add-database.md +16 -0
- package/harness/playbooks/new-endpoint.md +88 -0
- package/harness/playbooks/new-module.md +21 -0
- package/harness/playbooks/new-monitor-widget.md +31 -0
- package/harness/playbooks/review.md +41 -0
- package/harness/playbooks/update-node-lts.md +46 -0
- package/harness/reference/configuracion.md +111 -0
- package/harness/reference/contrato-http.md +169 -0
- package/harness/reference/novedades.json +158 -0
- package/harness/reference/progress-plantilla.md +50 -0
- package/harness/skills/commit-hygiene/SKILL.md +137 -0
- package/harness/skills/nest-base/SKILL.md +168 -0
- package/harness/skills/nest-base/templates/config/auth.spec.ts +32 -0
- package/harness/skills/nest-base/templates/config/auth.ts +80 -0
- package/harness/skills/nest-base/templates/config/configuration.spec.ts +85 -0
- package/harness/skills/nest-base/templates/config/configuration.ts +137 -0
- package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
- package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
- package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +27 -0
- package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
- package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
- package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
- package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +151 -0
- package/harness/skills/nest-base/templates/editorconfig +12 -0
- package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
- package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
- package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
- package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
- package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
- package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
- package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
- package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
- package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
- package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
- package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
- package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +84 -0
- package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
- package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
- package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
- package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
- package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
- package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
- package/harness/skills/nest-base/templates/prettierignore +6 -0
- package/harness/skills/nest-base/templates/prettierrc.json +8 -0
- package/harness/skills/nest-base/templates/security/login-attempts.spec.ts +48 -0
- package/harness/skills/nest-base/templates/security/login-attempts.ts +90 -0
- package/harness/skills/nest-base/templates/security/security-headers.spec.ts +27 -0
- package/harness/skills/nest-base/templates/security/security-headers.ts +21 -0
- package/harness/skills/nest-base/templates/security/security.module.ts +20 -0
- package/harness/skills/nest-base/templates/security/session-lifetime.spec.ts +21 -0
- package/harness/skills/nest-base/templates/security/session-lifetime.ts +28 -0
- package/package.json +44 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Plantilla del harness → src/config/configuration.spec.ts
|
|
2
|
+
import { buildConfiguration, type Env } from './configuration';
|
|
3
|
+
|
|
4
|
+
const base: Env = {
|
|
5
|
+
DATABASES: 'MAIN',
|
|
6
|
+
DB_MAIN_HOST: 'localhost',
|
|
7
|
+
DB_MAIN_NAME: 'analytics',
|
|
8
|
+
DB_MAIN_USER: 'readonly_user',
|
|
9
|
+
DB_MAIN_PASSWORD: 'change-me',
|
|
10
|
+
ACCESS_TOKEN: 'example123',
|
|
11
|
+
JWT_ACCESS_SECRET: 'access-secret',
|
|
12
|
+
JWT_REFRESH_SECRET: 'refresh-secret',
|
|
13
|
+
CORS_ORIGINS: 'http://localhost:4200',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('buildConfiguration', () => {
|
|
17
|
+
it('groups variables by topic and applies non-sensitive defaults', () => {
|
|
18
|
+
const config = buildConfiguration(base);
|
|
19
|
+
expect(config.app).toEqual({ nodeEnv: 'development', port: 3000, trustProxyHops: 0 });
|
|
20
|
+
expect(config.logging).toEqual({ level: 'info', format: 'json' });
|
|
21
|
+
expect(config.databases.MAIN).toEqual({
|
|
22
|
+
host: 'localhost',
|
|
23
|
+
port: 5432,
|
|
24
|
+
name: 'analytics',
|
|
25
|
+
user: 'readonly_user',
|
|
26
|
+
password: 'change-me',
|
|
27
|
+
ssl: false,
|
|
28
|
+
poolMax: 10,
|
|
29
|
+
statementTimeoutMs: 30000,
|
|
30
|
+
});
|
|
31
|
+
expect(config.cache.enabled).toBe(false);
|
|
32
|
+
expect(config.auth.jwt.accessExpiresIn).toBe('15m');
|
|
33
|
+
expect(config.cors).toEqual({ origins: ['http://localhost:4200'], maxAgeSeconds: 600 });
|
|
34
|
+
expect(config.monitor).toEqual({ enabled: false, sessionExpiresIn: '15m' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('converts numbers, booleans and one block per database', () => {
|
|
38
|
+
const config = buildConfiguration({
|
|
39
|
+
...base,
|
|
40
|
+
PORT: '8080',
|
|
41
|
+
DATABASES: 'MAIN, reports',
|
|
42
|
+
DB_MAIN_SSL: 'true',
|
|
43
|
+
DB_REPORTS_HOST: 'replica',
|
|
44
|
+
DB_REPORTS_PORT: '6432',
|
|
45
|
+
DB_REPORTS_NAME: 'reports',
|
|
46
|
+
DB_REPORTS_USER: 'ro',
|
|
47
|
+
DB_REPORTS_PASSWORD: 'x',
|
|
48
|
+
CACHE_ENABLED: 'true',
|
|
49
|
+
REDIS_URL: 'redis://localhost:6379',
|
|
50
|
+
});
|
|
51
|
+
expect(config.app.port).toBe(8080);
|
|
52
|
+
expect(config.databases.MAIN.ssl).toBe(true);
|
|
53
|
+
expect(config.databases.REPORTS.port).toBe(6432);
|
|
54
|
+
expect(config.cache).toMatchObject({ enabled: true, redisUrl: 'redis://localhost:6379' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each(['ACCESS_TOKEN', 'JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET', 'DB_MAIN_PASSWORD'])(
|
|
58
|
+
'fails without the secret %s and does not print any value',
|
|
59
|
+
(name) => {
|
|
60
|
+
const env = { ...base, [name]: '' };
|
|
61
|
+
expect(() => buildConfiguration(env)).toThrow(`${name} is required`);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
it('requires REDIS_URL only when the cache is enabled', () => {
|
|
66
|
+
expect(() => buildConfiguration({ ...base, CACHE_ENABLED: 'true' })).toThrow(
|
|
67
|
+
'REDIS_URL is required',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not allow pretty logs in production', () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
buildConfiguration({ ...base, NODE_ENV: 'production', LOG_FORMAT: 'pretty' }),
|
|
74
|
+
).toThrow('not allowed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
['PORT', 'abc', /integer/],
|
|
79
|
+
['MONITOR_ENABLED', 'yes', /true or false/],
|
|
80
|
+
['LOG_FORMAT', 'xml', /json or pretty/],
|
|
81
|
+
['HTTP_TRUST_PROXY_HOPS', '11', /between 0 and 10/],
|
|
82
|
+
])('rejects %s=%p', (name, value, message) => {
|
|
83
|
+
expect(() => buildConfiguration({ ...base, [name]: value })).toThrow(message);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Plantilla del harness → src/config/configuration.ts (AGENTS.md §14, harness/reference/configuracion.md).
|
|
2
|
+
// Single place in the project that reads process.env. The rest of the code receives AppConfig
|
|
3
|
+
// through ConfigService<AppConfig, true>. Variables are validated at startup (env validation in
|
|
4
|
+
// src/config/); this file only groups them and converts their types.
|
|
5
|
+
import { type LogFormat, parseLogFormat } from '../common/logging/logger-options';
|
|
6
|
+
import { type AuthConfig, parseAuthConfig } from './auth';
|
|
7
|
+
import { parseCorsMaxAge, parseCorsOrigins } from './cors';
|
|
8
|
+
|
|
9
|
+
export type Env = Record<string, string | undefined>;
|
|
10
|
+
|
|
11
|
+
export interface DatabaseConfig {
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
name: string;
|
|
15
|
+
user: string;
|
|
16
|
+
password: string;
|
|
17
|
+
ssl: boolean;
|
|
18
|
+
poolMax: number;
|
|
19
|
+
statementTimeoutMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppConfig {
|
|
23
|
+
app: { nodeEnv: string; port: number; trustProxyHops: number };
|
|
24
|
+
logging: { level: string; format: LogFormat };
|
|
25
|
+
databases: Record<string, DatabaseConfig>;
|
|
26
|
+
cache: {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
redisUrl: string | undefined;
|
|
29
|
+
defaultTtlSeconds: number;
|
|
30
|
+
keyPrefix: string;
|
|
31
|
+
opTimeoutMs: number;
|
|
32
|
+
};
|
|
33
|
+
auth: AuthConfig;
|
|
34
|
+
cors: { origins: string[]; maxAgeSeconds: number };
|
|
35
|
+
monitor: { enabled: boolean; sessionExpiresIn: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Required variable. The error names the variable, never its value. Secrets have no default. */
|
|
39
|
+
export function required(env: Env, name: string): string {
|
|
40
|
+
const value = env[name];
|
|
41
|
+
if (value === undefined || value.trim() === '') {
|
|
42
|
+
throw new Error(`${name} is required`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Optional text variable with a non-sensitive default. */
|
|
48
|
+
export function text(env: Env, name: string, fallback: string): string {
|
|
49
|
+
const value = env[name];
|
|
50
|
+
return value === undefined || value.trim() === '' ? fallback : value.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function int(env: Env, name: string, fallback: number): number {
|
|
54
|
+
const raw = env[name];
|
|
55
|
+
if (raw === undefined || raw.trim() === '') return fallback;
|
|
56
|
+
const value = Number(raw);
|
|
57
|
+
if (!Number.isInteger(value)) {
|
|
58
|
+
throw new Error(`${name} must be an integer`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function bool(env: Env, name: string, fallback: boolean): boolean {
|
|
64
|
+
const raw = env[name]?.trim().toLowerCase();
|
|
65
|
+
if (raw === undefined || raw === '') return fallback;
|
|
66
|
+
if (raw === 'true') return true;
|
|
67
|
+
if (raw === 'false') return false;
|
|
68
|
+
throw new Error(`${name} must be true or false`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** DATABASES=MAIN,REPORTS → one block per name, read from DB_<NAME>_*. */
|
|
72
|
+
export function parseDatabases(env: Env): Record<string, DatabaseConfig> {
|
|
73
|
+
const names = required(env, 'DATABASES')
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((name) => name.trim().toUpperCase())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
return Object.fromEntries(
|
|
78
|
+
names.map((name): [string, DatabaseConfig] => {
|
|
79
|
+
const key = (part: string): string => `DB_${name}_${part}`;
|
|
80
|
+
return [
|
|
81
|
+
name,
|
|
82
|
+
{
|
|
83
|
+
host: required(env, key('HOST')),
|
|
84
|
+
port: int(env, key('PORT'), 5432),
|
|
85
|
+
name: required(env, key('NAME')),
|
|
86
|
+
user: required(env, key('USER')),
|
|
87
|
+
password: required(env, key('PASSWORD')),
|
|
88
|
+
ssl: bool(env, key('SSL'), false),
|
|
89
|
+
poolMax: int(env, key('POOL_MAX'), 10),
|
|
90
|
+
statementTimeoutMs: int(env, key('STATEMENT_TIMEOUT_MS'), 30000),
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Proxies in front of the app (Azure App Service, gateway): 0 = connect directly, use the socket IP. */
|
|
98
|
+
export function trustProxyHops(env: Env): number {
|
|
99
|
+
const hops = int(env, 'HTTP_TRUST_PROXY_HOPS', 0);
|
|
100
|
+
if (hops < 0 || hops > 10) {
|
|
101
|
+
throw new Error('HTTP_TRUST_PROXY_HOPS must be an integer between 0 and 10');
|
|
102
|
+
}
|
|
103
|
+
return hops;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Pure: builds the configuration from an env object (tests pass their own). */
|
|
107
|
+
export function buildConfiguration(env: Env): AppConfig {
|
|
108
|
+
const cacheEnabled = bool(env, 'CACHE_ENABLED', false);
|
|
109
|
+
const nodeEnv = text(env, 'NODE_ENV', 'development');
|
|
110
|
+
return {
|
|
111
|
+
app: { nodeEnv, port: int(env, 'PORT', 3000), trustProxyHops: trustProxyHops(env) },
|
|
112
|
+
logging: {
|
|
113
|
+
level: text(env, 'LOG_LEVEL', 'info'),
|
|
114
|
+
format: parseLogFormat(env.LOG_FORMAT, nodeEnv),
|
|
115
|
+
},
|
|
116
|
+
databases: parseDatabases(env),
|
|
117
|
+
cache: {
|
|
118
|
+
enabled: cacheEnabled,
|
|
119
|
+
redisUrl: cacheEnabled ? required(env, 'REDIS_URL') : env.REDIS_URL,
|
|
120
|
+
defaultTtlSeconds: int(env, 'CACHE_DEFAULT_TTL_SECONDS', 300),
|
|
121
|
+
keyPrefix: text(env, 'CACHE_KEY_PREFIX', 'arness'),
|
|
122
|
+
opTimeoutMs: int(env, 'CACHE_OP_TIMEOUT_MS', 200),
|
|
123
|
+
},
|
|
124
|
+
auth: parseAuthConfig(env),
|
|
125
|
+
cors: {
|
|
126
|
+
origins: parseCorsOrigins(env.CORS_ORIGINS),
|
|
127
|
+
maxAgeSeconds: parseCorsMaxAge(env.CORS_MAX_AGE_SECONDS),
|
|
128
|
+
},
|
|
129
|
+
monitor: {
|
|
130
|
+
enabled: bool(env, 'MONITOR_ENABLED', false),
|
|
131
|
+
sessionExpiresIn: text(env, 'MONITOR_SESSION_EXPIRES_IN', '15m'),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Factory for ConfigModule.forRoot({ load: [configuration] }). */
|
|
137
|
+
export default (): AppConfig => buildConfiguration(process.env);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Plantilla del harness → src/config/cors.spec.ts
|
|
2
|
+
import { buildCorsOptions, parseCorsMaxAge, parseCorsOrigins } from './cors';
|
|
3
|
+
|
|
4
|
+
describe('parseCorsOrigins', () => {
|
|
5
|
+
it('returns normalized, unique origins', () => {
|
|
6
|
+
expect(
|
|
7
|
+
parseCorsOrigins(' https://app.example.com ,http://localhost:4200,https://app.example.com/'),
|
|
8
|
+
).toEqual(['https://app.example.com', 'http://localhost:4200']);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it.each([
|
|
12
|
+
['', /required/],
|
|
13
|
+
[undefined, /required/],
|
|
14
|
+
['*', /wildcards/],
|
|
15
|
+
['https://*.example.com', /wildcards/],
|
|
16
|
+
['not-a-url', /invalid origin/],
|
|
17
|
+
['https://app.example.com/path', /origins only/],
|
|
18
|
+
['http://app.example.com', /https/],
|
|
19
|
+
])('rejects %p', (raw, message) => {
|
|
20
|
+
expect(() => parseCorsOrigins(raw)).toThrow(message);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('parseCorsMaxAge', () => {
|
|
25
|
+
it('defaults to 600 and validates the range', () => {
|
|
26
|
+
expect(parseCorsMaxAge(undefined)).toBe(600);
|
|
27
|
+
expect(parseCorsMaxAge('120')).toBe(120);
|
|
28
|
+
expect(() => parseCorsMaxAge('-1')).toThrow();
|
|
29
|
+
expect(() => parseCorsMaxAge('abc')).toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('buildCorsOptions', () => {
|
|
34
|
+
const options = buildCorsOptions(['https://app.example.com'], 600);
|
|
35
|
+
const decide = (origin: string | undefined): boolean | undefined => {
|
|
36
|
+
let result: boolean | undefined;
|
|
37
|
+
(
|
|
38
|
+
options.origin as (
|
|
39
|
+
o: string | undefined,
|
|
40
|
+
cb: (e: Error | null, allow?: boolean) => void,
|
|
41
|
+
) => void
|
|
42
|
+
)(origin, (_err, allow) => (result = allow));
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
it('allows only origins in the allowlist and never uses credentials', () => {
|
|
47
|
+
expect(decide('https://app.example.com')).toBe(true);
|
|
48
|
+
expect(decide('https://evil.example.com')).toBe(false);
|
|
49
|
+
expect(decide(undefined)).toBe(false);
|
|
50
|
+
expect(options.credentials).toBe(false);
|
|
51
|
+
expect(options.methods).toEqual(['GET', 'POST', 'OPTIONS']);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Plantilla del harness → src/config/cors.ts (AGENTS.md §9, harness/reference/contrato-http.md "CORS").
|
|
2
|
+
import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
|
|
3
|
+
|
|
4
|
+
export const CORS_METHODS = ['GET', 'POST', 'OPTIONS'];
|
|
5
|
+
export const CORS_ALLOWED_HEADERS = ['Authorization', 'Content-Type', 'X-Request-Id'];
|
|
6
|
+
export const CORS_EXPOSED_HEADERS = ['X-Cache', 'X-Request-Id'];
|
|
7
|
+
export const CORS_DEFAULT_MAX_AGE_SECONDS = 600;
|
|
8
|
+
|
|
9
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses CORS_ORIGINS (comma separated) into exact origins.
|
|
13
|
+
* Throws on an empty list, wildcards, values that are not an origin, or http:// outside localhost.
|
|
14
|
+
*/
|
|
15
|
+
export function parseCorsOrigins(raw: string | undefined): string[] {
|
|
16
|
+
const items = (raw ?? '')
|
|
17
|
+
.split(',')
|
|
18
|
+
.map((item) => item.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
if (items.length === 0) {
|
|
21
|
+
throw new Error('CORS_ORIGINS is required: list the allowed origins separated by commas');
|
|
22
|
+
}
|
|
23
|
+
return [
|
|
24
|
+
...new Set(
|
|
25
|
+
items.map((item) => {
|
|
26
|
+
if (item.includes('*')) {
|
|
27
|
+
throw new Error(`CORS_ORIGINS does not accept wildcards: ${item}`);
|
|
28
|
+
}
|
|
29
|
+
let url: URL;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(item);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`CORS_ORIGINS has an invalid origin: ${item}`);
|
|
34
|
+
}
|
|
35
|
+
if (url.origin === 'null' || url.origin !== item.replace(/\/$/, '')) {
|
|
36
|
+
throw new Error(`CORS_ORIGINS must contain origins only (scheme://host[:port]): ${item}`);
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
url.protocol !== 'https:' &&
|
|
40
|
+
!(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`CORS_ORIGINS requires https:// except for localhost: ${item}`);
|
|
43
|
+
}
|
|
44
|
+
return url.origin;
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parses CORS_MAX_AGE_SECONDS; defaults to 600 seconds. */
|
|
51
|
+
export function parseCorsMaxAge(raw: string | undefined): number {
|
|
52
|
+
if (raw === undefined || raw.trim() === '') return CORS_DEFAULT_MAX_AGE_SECONDS;
|
|
53
|
+
const value = Number(raw);
|
|
54
|
+
if (!Number.isInteger(value) || value < 0 || value > 86400) {
|
|
55
|
+
throw new Error('CORS_MAX_AGE_SECONDS must be an integer between 0 and 86400');
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options for app.enableCors(): only origins in the allowlist get Access-Control-Allow-Origin. */
|
|
61
|
+
export function buildCorsOptions(origins: readonly string[], maxAgeSeconds: number): CorsOptions {
|
|
62
|
+
const allowed = new Set(origins);
|
|
63
|
+
return {
|
|
64
|
+
origin: (
|
|
65
|
+
origin: string | undefined,
|
|
66
|
+
callback: (err: Error | null, allow?: boolean) => void,
|
|
67
|
+
): void => callback(null, origin !== undefined && allowed.has(origin)),
|
|
68
|
+
methods: CORS_METHODS,
|
|
69
|
+
allowedHeaders: CORS_ALLOWED_HEADERS,
|
|
70
|
+
exposedHeaders: CORS_EXPOSED_HEADERS,
|
|
71
|
+
credentials: false,
|
|
72
|
+
maxAge: maxAgeSeconds,
|
|
73
|
+
optionsSuccessStatus: 204,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!-- Plantilla del harness → docs/EVOLUTIVO.md (AGENTS.md §12). Quita este comentario al copiarla. -->
|
|
2
|
+
# Evolutivo
|
|
3
|
+
|
|
4
|
+
Trazabilidad de lo que se ha entregado en el proyecto, en orden cronológico. Cada entrega agrega una entrada al final; las anteriores no se reescriben ni se borran.
|
|
5
|
+
|
|
6
|
+
**Solo avance.** Aquí no van pendientes, bloqueos, stoppers ni riesgos: esos van en `docs/PENDIENTES.md`.
|
|
7
|
+
|
|
8
|
+
Formato de cada entrada (lenguaje entendible para negocio):
|
|
9
|
+
|
|
10
|
+
```markdown
|
|
11
|
+
## AAAA-MM-DD HH:mm -0500 · <cliente>/<módulo>
|
|
12
|
+
|
|
13
|
+
- **Responsable:** <nombre de quien hizo la entrega>.
|
|
14
|
+
- **Entregado:** <qué se puede hacer ahora, en una o dos frases>.
|
|
15
|
+
- **Detalle técnico:** <endpoint, conexión, widget o cambio de contrato>.
|
|
16
|
+
- **Referencia:** <commit, PR o tag>.
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **Fecha y hora:** hora de Bogotá, Colombia (UTC-5), del momento en que se registra la entrega: `TZ=America/Bogota date '+%Y-%m-%d %H:%M %z'`.
|
|
20
|
+
- **Responsable:** la persona que trabaja en ese momento, según `git config user.name`. Si está vacío, se le pregunta; nunca se pone el nombre de un agente.
|
|
21
|
+
|
|
22
|
+
## AAAA-MM-DD HH:mm -0500 · Base del proyecto
|
|
23
|
+
|
|
24
|
+
- **Responsable:** <git config user.name>.
|
|
25
|
+
- **Entregado:** base del backend lista para agregar reportes: autenticación, salud del servicio y caché.
|
|
26
|
+
- **Detalle técnico:** proyecto NestJS creado con la skill `nest-base` del harness.
|
|
27
|
+
- **Referencia:** <commit inicial>.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!-- Plantilla del harness → docs/PENDIENTES.md (AGENTS.md §12). Quita este comentario al copiarla. -->
|
|
2
|
+
# Pendientes
|
|
3
|
+
|
|
4
|
+
Pendientes, bloqueos, stoppers, recomendaciones, deuda técnica y advertencias de `./harness/init.sh` que no se corrigieron. Lo entregado va en `docs/EVOLUTIVO.md`.
|
|
5
|
+
|
|
6
|
+
Cada ítem lleva fecha, contexto y tipo. Al resolverse, se mueve a **Resueltos** con la fecha de cierre (y, si fue una entrega, se registra en `docs/EVOLUTIVO.md`).
|
|
7
|
+
|
|
8
|
+
Tipos: `pendiente` · `bloqueo` · `recomendación` · `deuda técnica` · `advertencia init.sh`.
|
|
9
|
+
|
|
10
|
+
## Abiertos
|
|
11
|
+
|
|
12
|
+
| Fecha | Contexto (cliente/módulo) | Tipo | Descripción | Responsable |
|
|
13
|
+
|---|---|---|---|---|
|
|
14
|
+
| | | | | |
|
|
15
|
+
|
|
16
|
+
## Resueltos
|
|
17
|
+
|
|
18
|
+
| Fecha | Cierre | Contexto | Tipo | Descripción |
|
|
19
|
+
|---|---|---|---|---|
|
|
20
|
+
| | | | | |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!-- Plantilla del harness → docs/TECNICO.md (AGENTS.md §12). Quita este comentario al copiarla. -->
|
|
2
|
+
# Documentación técnica
|
|
3
|
+
|
|
4
|
+
Backend de analítica de **<cliente o producto>**: reportes de solo lectura sobre PostgreSQL. Las reglas de trabajo están en `AGENTS.md`; el contrato con frontend, en `FRONT.md`.
|
|
5
|
+
|
|
6
|
+
## Arquitectura
|
|
7
|
+
|
|
8
|
+
- NestJS por capas hexagonales por endpoint: controller → service (Cache-Aside) → repository (`pg`) → `*.sql.ts`.
|
|
9
|
+
- Organización `src/modules/<cliente>/<modulo>/<endpoint>/`; lo transversal vive en `src/common/`.
|
|
10
|
+
- Autenticación JWT (access + refresh) en todos los endpoints salvo `login`, `refresh` y `health`.
|
|
11
|
+
- Caché Redis opcional: la API responde igual si Redis está caído o deshabilitado.
|
|
12
|
+
|
|
13
|
+
## Módulos
|
|
14
|
+
|
|
15
|
+
| Cliente | Módulo | Propósito | Conexión | README |
|
|
16
|
+
|---|---|---|---|---|
|
|
17
|
+
| | | | | |
|
|
18
|
+
|
|
19
|
+
## Conexiones de base de datos
|
|
20
|
+
|
|
21
|
+
Solo nombres lógicos y origen; **nunca** hosts, usuarios ni contraseñas.
|
|
22
|
+
|
|
23
|
+
| Nombre (`DATABASES`) | Propósito | Esquemas o vistas principales |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| MAIN | | |
|
|
26
|
+
|
|
27
|
+
## Configuración
|
|
28
|
+
|
|
29
|
+
Variables por tema en `src/config/configuration.ts` y referencia completa en `harness/reference/configuracion.md`. Variables propias de este proyecto:
|
|
30
|
+
|
|
31
|
+
| Variable | Para qué | Obligatoria |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| | | |
|
|
34
|
+
|
|
35
|
+
## Despliegue
|
|
36
|
+
|
|
37
|
+
- Imagen Docker (`Dockerfile`) y pipeline `azure-pipelines.yml`.
|
|
38
|
+
- Entornos: <desarrollo, QA, producción> y sus URLs base.
|
|
39
|
+
- `GET /health` reporta cada BD y la caché; `GET /monitor` solo donde `MONITOR_ENABLED=true`.
|
|
40
|
+
|
|
41
|
+
## Decisiones
|
|
42
|
+
|
|
43
|
+
Las decisiones del proyecto están en `harness/decisiones/` (índice en su `README.md`).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Plantilla del harness → test/cors.e2e-spec.ts
|
|
2
|
+
import { Controller, Get, INestApplication } from '@nestjs/common';
|
|
3
|
+
import { Test } from '@nestjs/testing';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { App } from 'supertest/types';
|
|
6
|
+
import { buildCorsOptions } from '../src/config/cors';
|
|
7
|
+
|
|
8
|
+
@Controller('probe')
|
|
9
|
+
class ProbeController {
|
|
10
|
+
@Get()
|
|
11
|
+
get(): { data: string } {
|
|
12
|
+
return { data: 'ok' };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('CORS (e2e)', () => {
|
|
17
|
+
let app: INestApplication<App>;
|
|
18
|
+
const allowed = 'https://app.example.com';
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
const moduleRef = await Test.createTestingModule({ controllers: [ProbeController] }).compile();
|
|
22
|
+
app = moduleRef.createNestApplication();
|
|
23
|
+
app.enableCors(buildCorsOptions([allowed], 600));
|
|
24
|
+
await app.init();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await app.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('answers an allowed preflight with the CORS headers', async () => {
|
|
32
|
+
const res = await request(app.getHttpServer())
|
|
33
|
+
.options('/probe')
|
|
34
|
+
.set('Origin', allowed)
|
|
35
|
+
.set('Access-Control-Request-Method', 'GET')
|
|
36
|
+
.set('Access-Control-Request-Headers', 'Authorization');
|
|
37
|
+
expect(res.status).toBe(204);
|
|
38
|
+
expect(res.headers['access-control-allow-origin']).toBe(allowed);
|
|
39
|
+
expect(res.headers['access-control-allow-methods']).toBe('GET,POST,OPTIONS');
|
|
40
|
+
expect(res.headers['access-control-allow-headers']).toBe(
|
|
41
|
+
'Authorization,Content-Type,X-Request-Id',
|
|
42
|
+
);
|
|
43
|
+
expect(res.headers['access-control-max-age']).toBe('600');
|
|
44
|
+
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('exposes X-Cache and X-Request-Id to allowed origins', async () => {
|
|
48
|
+
const res = await request(app.getHttpServer()).get('/probe').set('Origin', allowed);
|
|
49
|
+
expect(res.headers['access-control-allow-origin']).toBe(allowed);
|
|
50
|
+
expect(res.headers['access-control-expose-headers']).toBe('X-Cache,X-Request-Id');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not allow an origin outside the allowlist', async () => {
|
|
54
|
+
const res = await request(app.getHttpServer())
|
|
55
|
+
.options('/probe')
|
|
56
|
+
.set('Origin', 'https://evil.example.com')
|
|
57
|
+
.set('Access-Control-Request-Method', 'GET');
|
|
58
|
+
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Plantilla del harness → test/monitor.e2e-spec.ts
|
|
2
|
+
import { Controller, Get, INestApplication, ValidationPipe } from '@nestjs/common';
|
|
3
|
+
import { ConfigModule } from '@nestjs/config';
|
|
4
|
+
import { Test } from '@nestjs/testing';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { App } from 'supertest/types';
|
|
7
|
+
import { MonitorModule } from '../src/common/monitor/monitor.module';
|
|
8
|
+
import { MONITOR_WIDGETS, MonitorWidget } from '../src/common/monitor/monitor-widget.interface';
|
|
9
|
+
import { EndpointsTotalWidget } from '../src/common/monitor/widgets/endpoints-total.widget';
|
|
10
|
+
import { SecurityModule } from '../src/common/security/security.module';
|
|
11
|
+
|
|
12
|
+
@Controller('cartera')
|
|
13
|
+
class CarteraController {
|
|
14
|
+
@Get('resumen')
|
|
15
|
+
summary(): void {}
|
|
16
|
+
|
|
17
|
+
@Get('detalle')
|
|
18
|
+
detail(): void {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Controller('health')
|
|
22
|
+
class HealthController {
|
|
23
|
+
@Get()
|
|
24
|
+
check(): void {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const brokenWidget: MonitorWidget = {
|
|
28
|
+
id: 'broken',
|
|
29
|
+
title: 'Widget roto',
|
|
30
|
+
order: 99,
|
|
31
|
+
collect: () => Promise.reject(new Error('connect ECONNREFUSED 10.0.0.5:5432')),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async function createApp(enabled: boolean): Promise<INestApplication<App>> {
|
|
35
|
+
const moduleRef = await Test.createTestingModule({
|
|
36
|
+
imports: [
|
|
37
|
+
ConfigModule.forRoot({
|
|
38
|
+
isGlobal: true,
|
|
39
|
+
ignoreEnvFile: true,
|
|
40
|
+
load: [
|
|
41
|
+
() => ({
|
|
42
|
+
MONITOR_ENABLED: String(enabled),
|
|
43
|
+
MONITOR_SESSION_EXPIRES_IN: '15m',
|
|
44
|
+
ACCESS_TOKEN: 'example123',
|
|
45
|
+
JWT_ACCESS_SECRET: 'e2e-access-secret-with-enough-length',
|
|
46
|
+
auth: { login: { maxAttempts: 3, windowSeconds: 60 } },
|
|
47
|
+
}),
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
SecurityModule,
|
|
51
|
+
MonitorModule,
|
|
52
|
+
],
|
|
53
|
+
controllers: [CarteraController, HealthController],
|
|
54
|
+
})
|
|
55
|
+
.overrideProvider(MONITOR_WIDGETS)
|
|
56
|
+
.useFactory({
|
|
57
|
+
factory: (total: EndpointsTotalWidget) => [total, brokenWidget],
|
|
58
|
+
inject: [EndpointsTotalWidget],
|
|
59
|
+
})
|
|
60
|
+
.compile();
|
|
61
|
+
const app = moduleRef.createNestApplication<INestApplication<App>>();
|
|
62
|
+
app.useGlobalPipes(
|
|
63
|
+
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
|
64
|
+
);
|
|
65
|
+
await app.init();
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('Monitor (e2e)', () => {
|
|
70
|
+
let app: INestApplication<App>;
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
app = await createApp(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await app.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const login = (password: string): request.Test =>
|
|
81
|
+
request(app.getHttpServer()).post('/monitor/login').type('form').send({ password });
|
|
82
|
+
|
|
83
|
+
it('redirects to the login form without a session', async () => {
|
|
84
|
+
const res = await request(app.getHttpServer()).get('/monitor');
|
|
85
|
+
expect(res.status).toBe(303);
|
|
86
|
+
expect(res.headers.location).toBe('/monitor/login');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('serves the login form with the security headers and no scripts', async () => {
|
|
90
|
+
const res = await request(app.getHttpServer()).get('/monitor/login');
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
expect(res.headers['content-type']).toMatch(/text\/html/);
|
|
93
|
+
expect(res.headers['content-security-policy']).toContain("default-src 'none'");
|
|
94
|
+
expect(res.headers['cache-control']).toBe('no-store');
|
|
95
|
+
expect(res.headers['x-frame-options']).toBe('DENY');
|
|
96
|
+
expect(res.text).not.toMatch(/<script/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects a wrong password with 401 and no cookie', async () => {
|
|
100
|
+
const res = await login('wrong');
|
|
101
|
+
expect(res.status).toBe(401);
|
|
102
|
+
expect(res.headers['set-cookie']).toBeUndefined();
|
|
103
|
+
expect(res.text).toContain('Contraseña incorrecta');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('logs in, shows the endpoint count and isolates a failing widget', async () => {
|
|
107
|
+
const res = await login('example123');
|
|
108
|
+
expect(res.status).toBe(303);
|
|
109
|
+
expect(res.headers.location).toBe('/monitor');
|
|
110
|
+
const cookie = String(res.headers['set-cookie']);
|
|
111
|
+
expect(cookie).toMatch(
|
|
112
|
+
/arness_monitor=.+; Max-Age=900; Path=\/monitor; HttpOnly; Secure; SameSite=Strict/,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const page = await request(app.getHttpServer())
|
|
116
|
+
.get('/monitor')
|
|
117
|
+
.set('Cookie', cookie.split(';')[0]);
|
|
118
|
+
expect(page.status).toBe(200);
|
|
119
|
+
expect(page.text).toMatch(/data-widget="endpoints-total".*?>2</);
|
|
120
|
+
expect(page.text).toContain('No disponible');
|
|
121
|
+
expect(page.text).not.toContain('ECONNREFUSED');
|
|
122
|
+
expect(page.text).not.toMatch(/<script/i);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('logout clears the cookie', async () => {
|
|
126
|
+
const res = await request(app.getHttpServer()).post('/monitor/logout');
|
|
127
|
+
expect(res.status).toBe(303);
|
|
128
|
+
expect(String(res.headers['set-cookie'])).toContain('arness_monitor=; Max-Age=0');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('answers 429 with Retry-After after too many wrong passwords, even with the right one', async () => {
|
|
132
|
+
const limited = await createApp(true);
|
|
133
|
+
const attempt = (password: string): request.Test =>
|
|
134
|
+
request(limited.getHttpServer()).post('/monitor/login').type('form').send({ password });
|
|
135
|
+
for (let i = 0; i < 3; i += 1) expect((await attempt('wrong')).status).toBe(401);
|
|
136
|
+
|
|
137
|
+
const res = await attempt('example123');
|
|
138
|
+
expect(res.status).toBe(429);
|
|
139
|
+
expect(Number(res.headers['retry-after'])).toBeGreaterThan(0);
|
|
140
|
+
expect(res.headers['set-cookie']).toBeUndefined();
|
|
141
|
+
expect(res.text).toContain('Demasiados intentos');
|
|
142
|
+
await limited.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('answers 404 when MONITOR_ENABLED=false', async () => {
|
|
146
|
+
const disabled = await createApp(false);
|
|
147
|
+
await request(disabled.getHttpServer()).get('/monitor').expect(404);
|
|
148
|
+
await request(disabled.getHttpServer()).get('/monitor/login').expect(404);
|
|
149
|
+
await disabled.close();
|
|
150
|
+
});
|
|
151
|
+
});
|