@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,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Fronteras de arquitectura del harness (AGENTS.md §4). Sin dependencias: solo
|
|
3
|
+
// @typescript-eslint/no-restricted-imports con patrones. Se copia a la raíz del proyecto y la
|
|
4
|
+
// importa eslint.config.mjs. Usa una regla distinta de no-restricted-imports (la de `pg`) para que
|
|
5
|
+
// los overrides de una no apaguen la otra. También exporta configBoundaries (process.env solo en
|
|
6
|
+
// src/config/, AGENTS.md §14).
|
|
7
|
+
|
|
8
|
+
const RULE = '@typescript-eslint/no-restricted-imports';
|
|
9
|
+
|
|
10
|
+
export const COMMON_IMPORTS_MODULES =
|
|
11
|
+
'src/common no importa de src/modules: lo compartido vive en common (AGENTS.md §4).';
|
|
12
|
+
export const MODULE_IMPORTS_OTHER_MODULE =
|
|
13
|
+
'Un módulo no importa de otro módulo ni de otro cliente: lo compartido va a src/common (AGENTS.md §4).';
|
|
14
|
+
export const MODULE_ABSOLUTE_IMPORT =
|
|
15
|
+
'Dentro de un módulo usa imports relativos; importar otro módulo está prohibido (AGENTS.md §4).';
|
|
16
|
+
export const PROCESS_ENV_OUTSIDE_CONFIG =
|
|
17
|
+
'process.env solo se lee en src/config/configuration.ts; usa ConfigService (AGENTS.md §13 y §14).';
|
|
18
|
+
|
|
19
|
+
/** Niveles de carpeta bajo src/modules/<cliente>/<modulo>/ que se verifican. */
|
|
20
|
+
const MAX_DEPTH = 5;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Un archivo a `depth` carpetas bajo la raíz de su módulo sale del módulo con depth + 1 `../`.
|
|
24
|
+
* Desde ahí solo puede llegar a src/common o src/config (dos niveles más arriba: cliente y modules).
|
|
25
|
+
*/
|
|
26
|
+
const leavesModule = (depth) => `^(\\.\\./){${depth + 1}}(?!\\.\\./\\.\\./(common|config)(/|$))`;
|
|
27
|
+
|
|
28
|
+
/** @type {import('eslint').Linter.Config[]} */
|
|
29
|
+
export const architectureBoundaries = [
|
|
30
|
+
{
|
|
31
|
+
files: ['src/common/**/*.ts'],
|
|
32
|
+
rules: {
|
|
33
|
+
[RULE]: ['error', { patterns: [{ regex: '(^|/)modules(/|$)', message: COMMON_IMPORTS_MODULES }] }],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
...Array.from({ length: MAX_DEPTH }, (_, depth) => ({
|
|
37
|
+
files: [`src/modules/*/*/${'*/'.repeat(depth)}*.ts`],
|
|
38
|
+
rules: {
|
|
39
|
+
[RULE]: [
|
|
40
|
+
'error',
|
|
41
|
+
{
|
|
42
|
+
patterns: [
|
|
43
|
+
{ regex: leavesModule(depth), message: MODULE_IMPORTS_OTHER_MODULE },
|
|
44
|
+
{ regex: '^src/modules/', message: MODULE_ABSOLUTE_IMPORT },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
})),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Configuración centralizada: process.env solo en src/config/. Los tests (specs y test/) pueden
|
|
54
|
+
* preparar variables. Usa no-restricted-properties, que ningún otro override del harness apaga.
|
|
55
|
+
* @type {import('eslint').Linter.Config[]}
|
|
56
|
+
*/
|
|
57
|
+
export const configBoundaries = [
|
|
58
|
+
{
|
|
59
|
+
files: ['src/**/*.ts'],
|
|
60
|
+
ignores: ['src/config/**', 'src/**/*.spec.ts'],
|
|
61
|
+
rules: {
|
|
62
|
+
'no-restricted-properties': ['error', { object: 'process', property: 'env', message: PROCESS_ENV_OUTSIDE_CONFIG }],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// ESLint del harness (AGENTS.md §4, §5, §6, §13 y §14).
|
|
3
|
+
import eslint from '@eslint/js';
|
|
4
|
+
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
|
5
|
+
import globals from 'globals';
|
|
6
|
+
import tseslint from 'typescript-eslint';
|
|
7
|
+
import { architectureBoundaries, configBoundaries } from './eslint.boundaries.mjs';
|
|
8
|
+
|
|
9
|
+
// Las palabras clave SQL se escriben en MAYÚSCULAS por convención. Detecta el SQL al inicio del
|
|
10
|
+
// literal, también tras saltos de línea o paréntesis (subconsultas), incluidas escrituras.
|
|
11
|
+
const SQL_TEXT = '^[\\s(]*(SELECT|WITH|INSERT|UPDATE|DELETE|MERGE)\\s';
|
|
12
|
+
const SQL_WRITE =
|
|
13
|
+
'\\b(INSERT\\s+INTO|UPDATE\\s+\\S+\\s+SET|DELETE\\s+FROM|MERGE\\s+INTO|TRUNCATE|DROP\\s|ALTER\\s|CREATE\\s|GRANT\\s|REVOKE\\s)';
|
|
14
|
+
|
|
15
|
+
const SQL_OUTSIDE_SQL_FILE = 'El texto SQL solo puede vivir en *.sql.ts (AGENTS.md §5).';
|
|
16
|
+
const SQL_WRITE_FORBIDDEN = 'La BD es de solo lectura: prohibido escribir o ejecutar DDL (AGENTS.md §6).';
|
|
17
|
+
|
|
18
|
+
export default tseslint.config(
|
|
19
|
+
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'eslint.config.mjs', 'eslint.boundaries.mjs'] },
|
|
20
|
+
eslint.configs.recommended,
|
|
21
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
22
|
+
eslintPluginPrettierRecommended,
|
|
23
|
+
{
|
|
24
|
+
languageOptions: {
|
|
25
|
+
globals: { ...globals.node, ...globals.jest },
|
|
26
|
+
sourceType: 'commonjs',
|
|
27
|
+
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
rules: {
|
|
32
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
33
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
34
|
+
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
|
35
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
36
|
+
'no-console': 'error',
|
|
37
|
+
eqeqeq: ['error', 'always'],
|
|
38
|
+
'no-restricted-imports': [
|
|
39
|
+
'error',
|
|
40
|
+
{
|
|
41
|
+
paths: [
|
|
42
|
+
{
|
|
43
|
+
name: 'pg',
|
|
44
|
+
message: 'pg solo se usa en src/common/database y en *.repository.ts (AGENTS.md §5).',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
'no-restricted-syntax': [
|
|
50
|
+
'error',
|
|
51
|
+
{ selector: `Literal[value=/${SQL_TEXT}/]`, message: SQL_OUTSIDE_SQL_FILE },
|
|
52
|
+
{ selector: `TemplateElement[value.raw=/${SQL_TEXT}/]`, message: SQL_OUTSIDE_SQL_FILE },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
// Fronteras: common no importa de modules y los módulos no se importan entre sí.
|
|
57
|
+
...architectureBoundaries,
|
|
58
|
+
// Configuración: process.env solo en src/config/ (configuration.ts).
|
|
59
|
+
...configBoundaries,
|
|
60
|
+
{
|
|
61
|
+
files: ['src/common/database/**/*.ts', 'src/**/*.repository.ts', 'src/**/*.repository.spec.ts'],
|
|
62
|
+
rules: { 'no-restricted-imports': 'off' },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
files: ['src/**/*.sql.ts'],
|
|
66
|
+
rules: {
|
|
67
|
+
'no-restricted-syntax': [
|
|
68
|
+
'error',
|
|
69
|
+
{ selector: `Literal[value=/${SQL_WRITE}/]`, message: SQL_WRITE_FORBIDDEN },
|
|
70
|
+
{ selector: `TemplateElement[value.raw=/${SQL_WRITE}/]`, message: SQL_WRITE_FORBIDDEN },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
files: ['src/**/*.sql.spec.ts'],
|
|
76
|
+
rules: { 'no-restricted-syntax': 'off' },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
|
80
|
+
rules: { '@typescript-eslint/unbound-method': 'off' },
|
|
81
|
+
},
|
|
82
|
+
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/logging/logger-options.spec.ts
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { buildLoggerParams, parseLogFormat, REDACT_PATHS, requestId } from './logger-options';
|
|
4
|
+
|
|
5
|
+
type PinoHttpOptions = Exclude<NonNullable<ReturnType<typeof buildLoggerParams>['pinoHttp']>, unknown[]>;
|
|
6
|
+
const options = (params: ReturnType<typeof buildLoggerParams>): PinoHttpOptions =>
|
|
7
|
+
params.pinoHttp as PinoHttpOptions;
|
|
8
|
+
|
|
9
|
+
describe('parseLogFormat', () => {
|
|
10
|
+
it('defaults to json and accepts pretty outside production', () => {
|
|
11
|
+
expect(parseLogFormat(undefined, 'production')).toBe('json');
|
|
12
|
+
expect(parseLogFormat(' Pretty ', 'development')).toBe('pretty');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects unknown formats and pretty in production', () => {
|
|
16
|
+
expect(() => parseLogFormat('xml', 'development')).toThrow('json or pretty');
|
|
17
|
+
expect(() => parseLogFormat('pretty', 'production')).toThrow('not allowed');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('buildLoggerParams', () => {
|
|
22
|
+
it('json: no transport and the same redaction as pretty', () => {
|
|
23
|
+
const json = options(buildLoggerParams({ level: 'info', format: 'json' }, true));
|
|
24
|
+
const pretty = options(buildLoggerParams({ level: 'debug', format: 'pretty' }, true));
|
|
25
|
+
expect(json.transport).toBeUndefined();
|
|
26
|
+
expect(pretty.transport).toEqual(expect.objectContaining({ target: 'pino-pretty' }));
|
|
27
|
+
expect(json.redact).toEqual(pretty.redact);
|
|
28
|
+
expect(REDACT_PATHS).toEqual(
|
|
29
|
+
expect.arrayContaining(['req.headers.authorization', 'req.headers.cookie', '*.password']),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('falls back to json when pino-pretty is not installed', () => {
|
|
34
|
+
expect(options(buildLoggerParams({ level: 'info', format: 'pretty' }, false)).transport).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('requestId', () => {
|
|
39
|
+
const call = (header?: string): { id: string; sent: string } => {
|
|
40
|
+
const res = { setHeader: jest.fn() };
|
|
41
|
+
const id = requestId(
|
|
42
|
+
{ headers: header === undefined ? {} : { 'x-request-id': header } } as IncomingMessage,
|
|
43
|
+
res as unknown as ServerResponse,
|
|
44
|
+
);
|
|
45
|
+
return { id, sent: res.setHeader.mock.calls[0][1] as string };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
it('reuses a safe X-Request-Id and returns it in the response', () => {
|
|
49
|
+
expect(call('abc-123')).toEqual({ id: 'abc-123', sent: 'abc-123' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it.each([undefined, '', 'bad id <script>', 'x'.repeat(129)])('generates a UUID for %p', (header) => {
|
|
53
|
+
const { id, sent } = call(header);
|
|
54
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
55
|
+
expect(sent).toBe(id);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/logging/logger-options.ts (AGENTS.md §10, harness/reference/contrato-http.md "Logging").
|
|
2
|
+
// JSON in production; one readable line per event in local development (LOG_FORMAT=pretty).
|
|
3
|
+
// Both formats carry the same fields and the same redaction.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
import type { Params } from 'nestjs-pino';
|
|
7
|
+
|
|
8
|
+
export const LOG_FORMATS = ['json', 'pretty'] as const;
|
|
9
|
+
export type LogFormat = (typeof LOG_FORMATS)[number];
|
|
10
|
+
|
|
11
|
+
export interface LoggingConfig {
|
|
12
|
+
level: string;
|
|
13
|
+
format: LogFormat;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Never logged in any format: credentials, tokens, session cookies and secrets. */
|
|
17
|
+
export const REDACT_PATHS = [
|
|
18
|
+
'req.headers.authorization',
|
|
19
|
+
'req.headers.cookie',
|
|
20
|
+
'res.headers["set-cookie"]',
|
|
21
|
+
'*.password',
|
|
22
|
+
'*.accessToken',
|
|
23
|
+
'*.refreshToken',
|
|
24
|
+
'*.ACCESS_TOKEN',
|
|
25
|
+
'*.JWT_ACCESS_SECRET',
|
|
26
|
+
'*.JWT_REFRESH_SECRET',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Incoming X-Request-Id is reused only if it is short and safe; otherwise a UUID is generated. */
|
|
30
|
+
const REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
31
|
+
|
|
32
|
+
export const PRETTY_OPTIONS = {
|
|
33
|
+
colorize: true,
|
|
34
|
+
singleLine: true,
|
|
35
|
+
translateTime: 'SYS:HH:MM:ss',
|
|
36
|
+
ignore: 'pid,hostname,req,res,requestId,responseTime',
|
|
37
|
+
messageFormat: '[{requestId}] {msg}',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Parses LOG_FORMAT (default json). pretty is rejected with NODE_ENV=production. */
|
|
41
|
+
export function parseLogFormat(raw: string | undefined, nodeEnv: string): LogFormat {
|
|
42
|
+
const value = (raw ?? '').trim().toLowerCase() || 'json';
|
|
43
|
+
if (!(LOG_FORMATS as readonly string[]).includes(value)) {
|
|
44
|
+
throw new Error('LOG_FORMAT must be json or pretty');
|
|
45
|
+
}
|
|
46
|
+
if (value === 'pretty' && nodeEnv === 'production') {
|
|
47
|
+
throw new Error('LOG_FORMAT=pretty is not allowed with NODE_ENV=production');
|
|
48
|
+
}
|
|
49
|
+
return value as LogFormat;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** pino-pretty is a devDependency: images built for production do not include it. */
|
|
53
|
+
export function isPrettyInstalled(): boolean {
|
|
54
|
+
try {
|
|
55
|
+
require.resolve('pino-pretty');
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function requestId(req: IncomingMessage, res: ServerResponse): string {
|
|
63
|
+
const header = req.headers['x-request-id'];
|
|
64
|
+
const incoming = typeof header === 'string' ? header.trim() : '';
|
|
65
|
+
const id = REQUEST_ID.test(incoming) ? incoming : randomUUID();
|
|
66
|
+
res.setHeader('X-Request-Id', id);
|
|
67
|
+
return id;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Options for LoggerModule.forRootAsync({ useFactory }). Falls back to JSON if pino-pretty is missing. */
|
|
71
|
+
export function buildLoggerParams(config: LoggingConfig, prettyInstalled = isPrettyInstalled()): Params {
|
|
72
|
+
const pretty = config.format === 'pretty' && prettyInstalled;
|
|
73
|
+
return {
|
|
74
|
+
pinoHttp: {
|
|
75
|
+
level: config.level,
|
|
76
|
+
genReqId: requestId,
|
|
77
|
+
customProps: (req) => ({ requestId: String(req.id) }),
|
|
78
|
+
customSuccessMessage: (req, res, responseTime) =>
|
|
79
|
+
`${req.method} ${req.url} ${res.statusCode} ${Math.round(responseTime)}ms`,
|
|
80
|
+
customErrorMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode} failed`,
|
|
81
|
+
redact: { paths: REDACT_PATHS, censor: '[REDACTED]' },
|
|
82
|
+
...(pretty ? { transport: { target: 'pino-pretty', options: PRETTY_OPTIONS } } : {}),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-login-redirect.filter.ts
|
|
2
|
+
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
|
|
3
|
+
import type { Response } from 'express';
|
|
4
|
+
import { MONITOR_LOGIN_URL, MONITOR_SECURITY_HEADERS } from './monitor.constants';
|
|
5
|
+
import { MonitorLoginRequiredException } from './monitor-session.guard';
|
|
6
|
+
|
|
7
|
+
@Catch(MonitorLoginRequiredException)
|
|
8
|
+
export class MonitorLoginRedirectFilter implements ExceptionFilter {
|
|
9
|
+
catch(_exception: MonitorLoginRequiredException, host: ArgumentsHost): void {
|
|
10
|
+
host
|
|
11
|
+
.switchToHttp()
|
|
12
|
+
.getResponse<Response>()
|
|
13
|
+
.set(MONITOR_SECURITY_HEADERS)
|
|
14
|
+
.redirect(303, MONITOR_LOGIN_URL);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-page.spec.ts
|
|
2
|
+
import { renderLoginPage, renderMonitorPage } from './monitor-page';
|
|
3
|
+
|
|
4
|
+
describe('monitor pages', () => {
|
|
5
|
+
const html = renderMonitorPage(
|
|
6
|
+
[
|
|
7
|
+
{
|
|
8
|
+
id: 'endpoints-total',
|
|
9
|
+
title: 'Endpoints de la API',
|
|
10
|
+
status: 'ok',
|
|
11
|
+
value: { value: 3, detail: [{ label: 'cartera', value: 3 }] },
|
|
12
|
+
},
|
|
13
|
+
{ id: 'broken', title: 'Roto', status: 'unavailable' },
|
|
14
|
+
],
|
|
15
|
+
new Date('2026-10-20T12:00:00Z'),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it('renders the widgets, "No disponible" and a logout form', () => {
|
|
19
|
+
expect(html.startsWith('<!doctype html>')).toBe(true);
|
|
20
|
+
expect(html).toContain('Endpoints de la API');
|
|
21
|
+
expect(html).toContain('cartera');
|
|
22
|
+
expect(html).toContain('No disponible');
|
|
23
|
+
expect(html).toContain('action="/monitor/logout"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('never ships scripts or inline event handlers', () => {
|
|
27
|
+
for (const page of [html, renderLoginPage('Contraseña incorrecta')]) {
|
|
28
|
+
expect(page).not.toMatch(/<script/i);
|
|
29
|
+
expect(page).not.toMatch(/\son[a-z]+=/i);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('escapes widget values', () => {
|
|
34
|
+
const page = renderMonitorPage(
|
|
35
|
+
[{ id: 'x', title: '<b>', status: 'ok', value: { value: '<script>' } }],
|
|
36
|
+
new Date(),
|
|
37
|
+
);
|
|
38
|
+
expect(page).not.toContain('<script>');
|
|
39
|
+
expect(page).toContain('<script>');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-page.ts
|
|
2
|
+
// HTML renderizado en el servidor con React, sin JSX, sin hidratación y sin JavaScript en el cliente.
|
|
3
|
+
import { createElement as h, type CSSProperties, type ReactNode } from 'react';
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
5
|
+
import { MONITOR_HOME_URL, MONITOR_LOGIN_URL } from './monitor.constants';
|
|
6
|
+
import type { MonitorCard } from './monitor.service';
|
|
7
|
+
|
|
8
|
+
const color = {
|
|
9
|
+
bg: '#f5f6f8',
|
|
10
|
+
card: '#ffffff',
|
|
11
|
+
text: '#1f2933',
|
|
12
|
+
muted: '#616e7c',
|
|
13
|
+
line: '#e4e7eb',
|
|
14
|
+
accent: '#2457c5',
|
|
15
|
+
warn: '#9a5b00',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const styles: Record<string, CSSProperties> = {
|
|
19
|
+
body: {
|
|
20
|
+
margin: 0,
|
|
21
|
+
background: color.bg,
|
|
22
|
+
color: color.text,
|
|
23
|
+
font: '15px/1.5 system-ui, -apple-system, Segoe UI, sans-serif',
|
|
24
|
+
},
|
|
25
|
+
header: {
|
|
26
|
+
display: 'flex',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: 16,
|
|
29
|
+
padding: '16px 24px',
|
|
30
|
+
background: color.card,
|
|
31
|
+
borderBottom: `1px solid ${color.line}`,
|
|
32
|
+
},
|
|
33
|
+
title: { margin: 0, fontSize: 18, fontWeight: 600, flex: 1 },
|
|
34
|
+
muted: { color: color.muted, fontSize: 13 },
|
|
35
|
+
main: {
|
|
36
|
+
padding: 24,
|
|
37
|
+
display: 'grid',
|
|
38
|
+
gap: 16,
|
|
39
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
|
40
|
+
},
|
|
41
|
+
card: {
|
|
42
|
+
background: color.card,
|
|
43
|
+
border: `1px solid ${color.line}`,
|
|
44
|
+
borderRadius: 10,
|
|
45
|
+
padding: 16,
|
|
46
|
+
},
|
|
47
|
+
cardTitle: {
|
|
48
|
+
margin: '0 0 8px',
|
|
49
|
+
fontSize: 13,
|
|
50
|
+
fontWeight: 600,
|
|
51
|
+
color: color.muted,
|
|
52
|
+
textTransform: 'uppercase',
|
|
53
|
+
letterSpacing: 0.4,
|
|
54
|
+
},
|
|
55
|
+
value: { fontSize: 32, fontWeight: 600, margin: 0 },
|
|
56
|
+
unavailable: { fontSize: 15, color: color.warn, margin: 0 },
|
|
57
|
+
list: { listStyle: 'none', padding: 0, margin: '12px 0 0', borderTop: `1px solid ${color.line}` },
|
|
58
|
+
row: {
|
|
59
|
+
display: 'flex',
|
|
60
|
+
justifyContent: 'space-between',
|
|
61
|
+
padding: '6px 0',
|
|
62
|
+
borderBottom: `1px solid ${color.line}`,
|
|
63
|
+
fontSize: 14,
|
|
64
|
+
},
|
|
65
|
+
button: {
|
|
66
|
+
font: 'inherit',
|
|
67
|
+
padding: '8px 14px',
|
|
68
|
+
borderRadius: 8,
|
|
69
|
+
border: `1px solid ${color.line}`,
|
|
70
|
+
background: color.card,
|
|
71
|
+
color: color.text,
|
|
72
|
+
cursor: 'pointer',
|
|
73
|
+
},
|
|
74
|
+
primary: {
|
|
75
|
+
font: 'inherit',
|
|
76
|
+
padding: '10px 14px',
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
border: 0,
|
|
79
|
+
background: color.accent,
|
|
80
|
+
color: '#fff',
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
width: '100%',
|
|
83
|
+
},
|
|
84
|
+
loginBox: {
|
|
85
|
+
maxWidth: 360,
|
|
86
|
+
margin: '12vh auto',
|
|
87
|
+
background: color.card,
|
|
88
|
+
border: `1px solid ${color.line}`,
|
|
89
|
+
borderRadius: 12,
|
|
90
|
+
padding: 24,
|
|
91
|
+
},
|
|
92
|
+
input: {
|
|
93
|
+
font: 'inherit',
|
|
94
|
+
width: '100%',
|
|
95
|
+
boxSizing: 'border-box',
|
|
96
|
+
padding: '10px 12px',
|
|
97
|
+
margin: '6px 0 16px',
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
border: `1px solid ${color.line}`,
|
|
100
|
+
},
|
|
101
|
+
error: { color: '#b42318', margin: '0 0 12px' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function document(title: string, body: ReactNode): string {
|
|
105
|
+
const html = h(
|
|
106
|
+
'html',
|
|
107
|
+
{ lang: 'es' },
|
|
108
|
+
h(
|
|
109
|
+
'head',
|
|
110
|
+
null,
|
|
111
|
+
h('meta', { charSet: 'utf-8' }),
|
|
112
|
+
h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
|
|
113
|
+
h('meta', { name: 'robots', content: 'noindex, nofollow' }),
|
|
114
|
+
h('title', null, title),
|
|
115
|
+
),
|
|
116
|
+
h('body', { style: styles.body }, body),
|
|
117
|
+
);
|
|
118
|
+
return `<!doctype html>${renderToStaticMarkup(html)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function card({ id, title, status, value }: MonitorCard): ReactNode {
|
|
122
|
+
return h(
|
|
123
|
+
'section',
|
|
124
|
+
{ key: id, style: styles.card, 'data-widget': id },
|
|
125
|
+
h('h2', { style: styles.cardTitle }, title),
|
|
126
|
+
status === 'ok' && value
|
|
127
|
+
? [
|
|
128
|
+
h('p', { key: 'v', style: styles.value }, String(value.value)),
|
|
129
|
+
value.detail?.length
|
|
130
|
+
? h(
|
|
131
|
+
'ul',
|
|
132
|
+
{ key: 'd', style: styles.list },
|
|
133
|
+
value.detail.map((item) =>
|
|
134
|
+
h(
|
|
135
|
+
'li',
|
|
136
|
+
{ key: item.label, style: styles.row },
|
|
137
|
+
h('span', null, item.label),
|
|
138
|
+
h('strong', null, String(item.value)),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
: null,
|
|
143
|
+
]
|
|
144
|
+
: h('p', { style: styles.unavailable }, 'No disponible'),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function renderMonitorPage(cards: readonly MonitorCard[], generatedAt: Date): string {
|
|
149
|
+
return document('Monitor', [
|
|
150
|
+
h(
|
|
151
|
+
'header',
|
|
152
|
+
{ key: 'h', style: styles.header },
|
|
153
|
+
h('h1', { style: styles.title }, 'Monitor'),
|
|
154
|
+
h('span', { style: styles.muted }, `Actualizado ${generatedAt.toISOString()}`),
|
|
155
|
+
h(
|
|
156
|
+
'form',
|
|
157
|
+
{ method: 'post', action: `${MONITOR_HOME_URL}/logout` },
|
|
158
|
+
h('button', { type: 'submit', style: styles.button }, 'Cerrar sesión'),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
h('main', { key: 'm', style: styles.main }, cards.map(card)),
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderLoginPage(error?: string): string {
|
|
166
|
+
return document(
|
|
167
|
+
'Monitor · Ingresar',
|
|
168
|
+
h(
|
|
169
|
+
'main',
|
|
170
|
+
{ style: styles.loginBox },
|
|
171
|
+
h('h1', { style: { ...styles.title, marginBottom: 16 } }, 'Monitor'),
|
|
172
|
+
error ? h('p', { style: styles.error, role: 'alert' }, error) : null,
|
|
173
|
+
h(
|
|
174
|
+
'form',
|
|
175
|
+
{ method: 'post', action: MONITOR_LOGIN_URL },
|
|
176
|
+
h('label', { htmlFor: 'password' }, 'Contraseña'),
|
|
177
|
+
h('input', {
|
|
178
|
+
id: 'password',
|
|
179
|
+
name: 'password',
|
|
180
|
+
type: 'password',
|
|
181
|
+
required: true,
|
|
182
|
+
autoComplete: 'current-password',
|
|
183
|
+
autoFocus: true,
|
|
184
|
+
style: styles.input,
|
|
185
|
+
}),
|
|
186
|
+
h('button', { type: 'submit', style: styles.primary }, 'Ingresar'),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-session.guard.spec.ts
|
|
2
|
+
import { ExecutionContext, NotFoundException } from '@nestjs/common';
|
|
3
|
+
import { Reflector } from '@nestjs/core';
|
|
4
|
+
import { MonitorLoginRequiredException, MonitorSessionGuard } from './monitor-session.guard';
|
|
5
|
+
import { MonitorSessionService } from './monitor-session.service';
|
|
6
|
+
|
|
7
|
+
const context = (cookie?: string): ExecutionContext =>
|
|
8
|
+
({
|
|
9
|
+
getHandler: () => undefined,
|
|
10
|
+
getClass: () => undefined,
|
|
11
|
+
switchToHttp: () => ({ getRequest: () => ({ headers: { cookie } }) }),
|
|
12
|
+
}) as unknown as ExecutionContext;
|
|
13
|
+
|
|
14
|
+
describe('MonitorSessionGuard', () => {
|
|
15
|
+
const session = { isEnabled: jest.fn(), hasValidSession: jest.fn() };
|
|
16
|
+
const reflector = { getAllAndOverride: jest.fn() };
|
|
17
|
+
const guard = new MonitorSessionGuard(
|
|
18
|
+
reflector as unknown as Reflector,
|
|
19
|
+
session as unknown as MonitorSessionService,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
session.isEnabled.mockReturnValue(true);
|
|
24
|
+
session.hasValidSession.mockResolvedValue(false);
|
|
25
|
+
reflector.getAllAndOverride.mockReturnValue(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('answers 404 when the monitor is disabled', async () => {
|
|
29
|
+
session.isEnabled.mockReturnValue(false);
|
|
30
|
+
await expect(guard.canActivate(context())).rejects.toBeInstanceOf(NotFoundException);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('lets login routes through without a session', async () => {
|
|
34
|
+
reflector.getAllAndOverride.mockReturnValue(true);
|
|
35
|
+
await expect(guard.canActivate(context())).resolves.toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('requires a valid session cookie', async () => {
|
|
39
|
+
await expect(guard.canActivate(context())).rejects.toBeInstanceOf(
|
|
40
|
+
MonitorLoginRequiredException,
|
|
41
|
+
);
|
|
42
|
+
session.hasValidSession.mockResolvedValue(true);
|
|
43
|
+
await expect(guard.canActivate(context('arness_monitor=t'))).resolves.toBe(true);
|
|
44
|
+
expect(session.hasValidSession).toHaveBeenCalledWith('arness_monitor=t');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plantilla del harness → src/common/monitor/monitor-session.guard.ts
|
|
2
|
+
import {
|
|
3
|
+
CanActivate,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
Injectable,
|
|
6
|
+
NotFoundException,
|
|
7
|
+
UnauthorizedException,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { Reflector } from '@nestjs/core';
|
|
10
|
+
import type { Request } from 'express';
|
|
11
|
+
import { IS_MONITOR_LOGIN_KEY } from './monitor.decorators';
|
|
12
|
+
import { MonitorSessionService } from './monitor-session.service';
|
|
13
|
+
|
|
14
|
+
/** Caught by MonitorLoginRedirectFilter, which redirects to the login form. */
|
|
15
|
+
export class MonitorLoginRequiredException extends UnauthorizedException {}
|
|
16
|
+
|
|
17
|
+
/** Cookie session for /monitor. 404 when MONITOR_ENABLED is not true. */
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class MonitorSessionGuard implements CanActivate {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly reflector: Reflector,
|
|
22
|
+
private readonly session: MonitorSessionService,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
26
|
+
if (!this.session.isEnabled()) throw new NotFoundException();
|
|
27
|
+
const isLogin = this.reflector.getAllAndOverride<boolean>(IS_MONITOR_LOGIN_KEY, [
|
|
28
|
+
context.getHandler(),
|
|
29
|
+
context.getClass(),
|
|
30
|
+
]);
|
|
31
|
+
if (isLogin) return true;
|
|
32
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
33
|
+
if (await this.session.hasValidSession(request.headers.cookie)) return true;
|
|
34
|
+
throw new MonitorLoginRequiredException();
|
|
35
|
+
}
|
|
36
|
+
}
|