autotel 3.4.4 → 3.6.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/dist/{chunk-O4JZUCUE.js → chunk-66YJ66GG.js} +5 -158
- package/dist/chunk-66YJ66GG.js.map +1 -0
- package/dist/{chunk-Z6HRSM2Y.cjs → chunk-B7SWBE4P.cjs} +5 -5
- package/dist/{chunk-Z6HRSM2Y.cjs.map → chunk-B7SWBE4P.cjs.map} +1 -1
- package/dist/{chunk-DQEHQNQE.js → chunk-D4TM63S3.js} +3 -3
- package/dist/{chunk-DQEHQNQE.js.map → chunk-D4TM63S3.js.map} +1 -1
- package/dist/chunk-DCEDJQGG.js +28 -0
- package/dist/chunk-DCEDJQGG.js.map +1 -0
- package/dist/chunk-E6TERL5O.cjs +23 -0
- package/dist/chunk-E6TERL5O.cjs.map +1 -0
- package/dist/chunk-EE6CPXKH.cjs +164 -0
- package/dist/chunk-EE6CPXKH.cjs.map +1 -0
- package/dist/{chunk-GBFTC7Q7.cjs → chunk-EOFB7XCL.cjs} +6 -6
- package/dist/{chunk-GBFTC7Q7.cjs.map → chunk-EOFB7XCL.cjs.map} +1 -1
- package/dist/{chunk-Z7PW3KHL.cjs → chunk-FMTHVSYY.cjs} +4 -163
- package/dist/chunk-FMTHVSYY.cjs.map +1 -0
- package/dist/{chunk-VG2ABKJX.cjs → chunk-KYXZS3EA.cjs} +7 -7
- package/dist/{chunk-VG2ABKJX.cjs.map → chunk-KYXZS3EA.cjs.map} +1 -1
- package/dist/chunk-LVIPBYFE.js +157 -0
- package/dist/chunk-LVIPBYFE.js.map +1 -0
- package/dist/{chunk-NVGPMGI4.js → chunk-N25JDZSC.js} +3 -3
- package/dist/{chunk-NVGPMGI4.js.map → chunk-N25JDZSC.js.map} +1 -1
- package/dist/{chunk-AC5GNZKB.cjs → chunk-NENU7E6V.cjs} +5 -5
- package/dist/{chunk-AC5GNZKB.cjs.map → chunk-NENU7E6V.cjs.map} +1 -1
- package/dist/{chunk-URHPSJW2.js → chunk-QF7ARNUM.js} +3 -3
- package/dist/{chunk-URHPSJW2.js.map → chunk-QF7ARNUM.js.map} +1 -1
- package/dist/chunk-T5WRA76K.cjs +32 -0
- package/dist/chunk-T5WRA76K.cjs.map +1 -0
- package/dist/{chunk-YWCESU4Y.js → chunk-T7JO2TCP.js} +3 -3
- package/dist/{chunk-YWCESU4Y.js.map → chunk-T7JO2TCP.js.map} +1 -1
- package/dist/{chunk-O7JOKRN2.js → chunk-UIKYE2QZ.js} +3 -3
- package/dist/{chunk-O7JOKRN2.js.map → chunk-UIKYE2QZ.js.map} +1 -1
- package/dist/chunk-UNPLAVE7.js +21 -0
- package/dist/chunk-UNPLAVE7.js.map +1 -0
- package/dist/{chunk-FGNDN2FD.cjs → chunk-V7UBMJAB.cjs} +18 -18
- package/dist/{chunk-FGNDN2FD.cjs.map → chunk-V7UBMJAB.cjs.map} +1 -1
- package/dist/correlation-id.cjs +10 -9
- package/dist/correlation-id.js +2 -1
- package/dist/decorators.cjs +4 -3
- package/dist/decorators.cjs.map +1 -1
- package/dist/decorators.js +3 -2
- package/dist/decorators.js.map +1 -1
- package/dist/define-event-BL6Li7CM.d.ts +23 -0
- package/dist/define-event-ClP3T1Jx.d.cts +23 -0
- package/dist/event.cjs +6 -5
- package/dist/event.js +3 -2
- package/dist/functional.cjs +11 -10
- package/dist/functional.js +3 -2
- package/dist/http.cjs +3 -2
- package/dist/http.cjs.map +1 -1
- package/dist/http.js +2 -1
- package/dist/http.js.map +1 -1
- package/dist/index.cjs +91 -102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -21
- package/dist/index.d.ts +2 -21
- package/dist/index.js +17 -27
- package/dist/index.js.map +1 -1
- package/dist/messaging.cjs +7 -6
- package/dist/messaging.js +4 -3
- package/dist/security-schema.cjs +69 -0
- package/dist/security-schema.cjs.map +1 -0
- package/dist/security-schema.d.cts +67 -0
- package/dist/security-schema.d.ts +67 -0
- package/dist/security-schema.js +59 -0
- package/dist/security-schema.js.map +1 -0
- package/dist/semantic-helpers.cjs +8 -7
- package/dist/semantic-helpers.js +4 -3
- package/dist/validate.cjs +138 -0
- package/dist/validate.cjs.map +1 -0
- package/dist/validate.d.cts +129 -0
- package/dist/validate.d.ts +129 -0
- package/dist/validate.js +133 -0
- package/dist/validate.js.map +1 -0
- package/dist/validation-attributes.cjs +20 -0
- package/dist/validation-attributes.cjs.map +1 -0
- package/dist/validation-attributes.d.cts +40 -0
- package/dist/validation-attributes.d.ts +40 -0
- package/dist/validation-attributes.js +3 -0
- package/dist/validation-attributes.js.map +1 -0
- package/dist/webhook.cjs +5 -4
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.js +3 -2
- package/dist/webhook.js.map +1 -1
- package/dist/workflow-distributed.cjs +5 -4
- package/dist/workflow-distributed.cjs.map +1 -1
- package/dist/workflow-distributed.js +3 -2
- package/dist/workflow-distributed.js.map +1 -1
- package/dist/workflow.cjs +8 -7
- package/dist/workflow.js +4 -3
- package/package.json +23 -8
- package/src/define-event.ts +2 -21
- package/src/security-schema.test.ts +45 -0
- package/src/security-schema.ts +107 -0
- package/src/stable-hash.ts +27 -0
- package/src/validate.test.ts +285 -0
- package/src/validate.ts +301 -0
- package/src/validation-attributes.ts +43 -0
- package/dist/chunk-O4JZUCUE.js.map +0 -1
- package/dist/chunk-Z7PW3KHL.cjs.map +0 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SECURITY_ATTR,
|
|
4
|
+
SECURITY_DENIED_STATUSES,
|
|
5
|
+
SECURITY_SEVERITIES,
|
|
6
|
+
SECURITY_SEVERITY_RANK,
|
|
7
|
+
escalateSecuritySeverity,
|
|
8
|
+
parseSecuritySeverity,
|
|
9
|
+
securitySeverityAtLeast,
|
|
10
|
+
} from './security-schema';
|
|
11
|
+
|
|
12
|
+
describe('security-schema', () => {
|
|
13
|
+
it('ranks severities in declaration order', () => {
|
|
14
|
+
const ranks = SECURITY_SEVERITIES.map((s) => SECURITY_SEVERITY_RANK[s]);
|
|
15
|
+
expect(ranks).toEqual([0, 1, 2, 3]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('parses valid severities and falls back on garbage', () => {
|
|
19
|
+
expect(parseSecuritySeverity('critical')).toBe('critical');
|
|
20
|
+
expect(parseSecuritySeverity('warning')).toBe('warning');
|
|
21
|
+
expect(parseSecuritySeverity('CRITICAL')).toBe('info');
|
|
22
|
+
expect(parseSecuritySeverity(42)).toBe('info');
|
|
23
|
+
expect(parseSecuritySeverity(undefined)).toBe('info');
|
|
24
|
+
expect(parseSecuritySeverity(undefined, 'warning')).toBe('warning');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('compares severities against a threshold', () => {
|
|
28
|
+
expect(securitySeverityAtLeast('error', 'warning')).toBe(true);
|
|
29
|
+
expect(securitySeverityAtLeast('warning', 'warning')).toBe(true);
|
|
30
|
+
expect(securitySeverityAtLeast('info', 'warning')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('escalates to the floor but never downgrades', () => {
|
|
34
|
+
expect(escalateSecuritySeverity('info', 'error')).toBe('error');
|
|
35
|
+
expect(escalateSecuritySeverity('error', 'error')).toBe('error');
|
|
36
|
+
expect(escalateSecuritySeverity('critical', 'error')).toBe('critical');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps the attribute contract stable', () => {
|
|
40
|
+
expect(SECURITY_ATTR.event).toBe('security.event');
|
|
41
|
+
expect(SECURITY_ATTR.severity).toBe('security.severity');
|
|
42
|
+
expect(SECURITY_ATTR.suspiciousRequest).toBe('security.suspicious_request');
|
|
43
|
+
expect(SECURITY_DENIED_STATUSES).toEqual([401, 403, 429]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security telemetry wire schema — the single source of truth for the
|
|
3
|
+
* `security.*` span-attribute contract emitted by `autotel-audit`
|
|
4
|
+
* (`securityEvent()`, `withSecurity()`, `createSecuritySignalProcessor()`)
|
|
5
|
+
* and consumed by `autotel-subscribers`, `autotel-devtools`, and the
|
|
6
|
+
* `autotel security` CLI commands.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free and side-effect-free by design: safe to import from
|
|
9
|
+
* browser bundles (devtools widget) and anything else that only needs
|
|
10
|
+
* the constants, without pulling in the OpenTelemetry SDK.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type SecuritySeverity = 'info' | 'warning' | 'error' | 'critical';
|
|
14
|
+
|
|
15
|
+
/** All severities, lowest first. */
|
|
16
|
+
export const SECURITY_SEVERITIES: readonly SecuritySeverity[] = [
|
|
17
|
+
'info',
|
|
18
|
+
'warning',
|
|
19
|
+
'error',
|
|
20
|
+
'critical',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Numeric rank per severity for threshold comparisons. */
|
|
24
|
+
export const SECURITY_SEVERITY_RANK: Record<SecuritySeverity, number> = {
|
|
25
|
+
info: 0,
|
|
26
|
+
warning: 1,
|
|
27
|
+
error: 2,
|
|
28
|
+
critical: 3,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse an untrusted value (span attribute, event payload field) into a
|
|
33
|
+
* severity, falling back when it is missing or malformed.
|
|
34
|
+
*/
|
|
35
|
+
export function parseSecuritySeverity(
|
|
36
|
+
value: unknown,
|
|
37
|
+
fallback: SecuritySeverity = 'info',
|
|
38
|
+
): SecuritySeverity {
|
|
39
|
+
return typeof value === 'string' && value in SECURITY_SEVERITY_RANK
|
|
40
|
+
? (value as SecuritySeverity)
|
|
41
|
+
: fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `true` when `severity` meets or exceeds `min`. */
|
|
45
|
+
export function securitySeverityAtLeast(
|
|
46
|
+
severity: SecuritySeverity,
|
|
47
|
+
min: SecuritySeverity,
|
|
48
|
+
): boolean {
|
|
49
|
+
return SECURITY_SEVERITY_RANK[severity] >= SECURITY_SEVERITY_RANK[min];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The higher-ranked of two severities (e.g. escalate failures to ≥ error). */
|
|
53
|
+
export function escalateSecuritySeverity(
|
|
54
|
+
severity: SecuritySeverity,
|
|
55
|
+
floor: SecuritySeverity,
|
|
56
|
+
): SecuritySeverity {
|
|
57
|
+
return SECURITY_SEVERITY_RANK[severity] >= SECURITY_SEVERITY_RANK[floor]
|
|
58
|
+
? severity
|
|
59
|
+
: floor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Span attribute keys of the security schema. Emitters and consumers must
|
|
64
|
+
* reference these instead of re-typing the strings.
|
|
65
|
+
*/
|
|
66
|
+
export const SECURITY_ATTR = {
|
|
67
|
+
/** Marker set on every span carrying a security event. */
|
|
68
|
+
marker: 'autotel.security',
|
|
69
|
+
/** Set when the event was force-kept through tail sampling. */
|
|
70
|
+
forceKeep: 'autotel.security.force_keep',
|
|
71
|
+
event: 'security.event',
|
|
72
|
+
category: 'security.category',
|
|
73
|
+
outcome: 'security.outcome',
|
|
74
|
+
severity: 'security.severity',
|
|
75
|
+
actorId: 'security.actor_id',
|
|
76
|
+
targetType: 'security.target_type',
|
|
77
|
+
targetId: 'security.target_id',
|
|
78
|
+
tenantId: 'security.tenant_id',
|
|
79
|
+
reason: 'security.reason',
|
|
80
|
+
/** Custom metadata keys dropped because they looked credential-shaped. */
|
|
81
|
+
droppedKeys: 'security.dropped_keys',
|
|
82
|
+
/** Set by the signal processor on suspicious request paths. */
|
|
83
|
+
suspiciousRequest: 'security.suspicious_request',
|
|
84
|
+
/** Pattern name that flagged a suspicious request, e.g. `path_traversal`. */
|
|
85
|
+
signal: 'security.signal',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
/** Metric names emitted by the security instrumentation. */
|
|
89
|
+
export const SECURITY_METRICS = {
|
|
90
|
+
events: 'autotel.security.events',
|
|
91
|
+
httpSuspicious: 'autotel.security.http.suspicious',
|
|
92
|
+
httpDenied: 'autotel.security.http.denied',
|
|
93
|
+
anomaly: 'autotel.security.anomaly',
|
|
94
|
+
heartbeat: 'autotel.security.heartbeat',
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
/** HTTP statuses counted as denied responses by default. */
|
|
98
|
+
export const SECURITY_DENIED_STATUSES: readonly number[] = [401, 403, 429];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Span attributes carrying the HTTP response status, current semconv
|
|
102
|
+
* first, legacy fallback second.
|
|
103
|
+
*/
|
|
104
|
+
export const HTTP_STATUS_ATTRIBUTES: readonly string[] = [
|
|
105
|
+
'http.response.status_code',
|
|
106
|
+
'http.status_code',
|
|
107
|
+
];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic JSON stringify with sorted object keys, so two structurally
|
|
5
|
+
* equal values always produce the same string regardless of key insertion
|
|
6
|
+
* order. Shared by `defineEvent` and the validation layer for stable schema
|
|
7
|
+
* hashes.
|
|
8
|
+
*/
|
|
9
|
+
export function stableStringify(value: unknown): string {
|
|
10
|
+
if (value === null || value === undefined || typeof value !== 'object') {
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return '[' + value.map((v) => stableStringify(v)).join(',') + ']';
|
|
15
|
+
}
|
|
16
|
+
const obj = value as Record<string, unknown>;
|
|
17
|
+
const body = Object.keys(obj)
|
|
18
|
+
.toSorted()
|
|
19
|
+
.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k]))
|
|
20
|
+
.join(',');
|
|
21
|
+
return '{' + body + '}';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stable sha256 of any JSON-serializable value. */
|
|
25
|
+
export function hashJson(value: unknown): string {
|
|
26
|
+
return createHash('sha256').update(stableStringify(value)).digest('hex');
|
|
27
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
|
|
4
|
+
const counterAdd = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock('./metric-helpers', () => ({
|
|
6
|
+
createCounter: () => ({ add: counterAdd }),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
defineValidator,
|
|
11
|
+
recordValidationMismatch,
|
|
12
|
+
formatValidationIssues,
|
|
13
|
+
onValidationMismatch,
|
|
14
|
+
type ValidationMismatch,
|
|
15
|
+
} from './validate';
|
|
16
|
+
import { VALIDATION_ATTR } from './validation-attributes';
|
|
17
|
+
|
|
18
|
+
/** A fake `SchemaLike` so tests don't depend on Zod. */
|
|
19
|
+
function schema<T>(
|
|
20
|
+
decide: (input: unknown) => { success: true; data: T } | { success: false; error: unknown },
|
|
21
|
+
) {
|
|
22
|
+
return { safeParse: decide };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A Zod-shaped error whose message/received embed a secret value. */
|
|
26
|
+
const SECRET = '123-45-6789';
|
|
27
|
+
const zodLikeError = {
|
|
28
|
+
issues: [
|
|
29
|
+
{
|
|
30
|
+
path: ['user', 'ssn'],
|
|
31
|
+
code: 'invalid_type',
|
|
32
|
+
expected: 'string',
|
|
33
|
+
received: SECRET, // value — must never escape
|
|
34
|
+
message: `Expected string, received ${SECRET}`, // value — must never escape
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let setAttributes: ReturnType<typeof vi.fn>;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
setAttributes = vi.fn();
|
|
43
|
+
vi.spyOn(trace, 'getActiveSpan').mockReturnValue({
|
|
44
|
+
setAttributes,
|
|
45
|
+
} as never);
|
|
46
|
+
counterAdd.mockClear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('formatValidationIssues — PII guard', () => {
|
|
54
|
+
it('keeps only path, code, and declared type — never values or messages', () => {
|
|
55
|
+
const issues = formatValidationIssues(zodLikeError);
|
|
56
|
+
expect(issues).toEqual([
|
|
57
|
+
{ path: 'user.ssn', code: 'invalid_type', expected: 'string' },
|
|
58
|
+
]);
|
|
59
|
+
// The secret must not appear anywhere in the serialized output.
|
|
60
|
+
expect(JSON.stringify(issues)).not.toContain(SECRET);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles a generic { errors: [...] } shape', () => {
|
|
64
|
+
const issues = formatValidationIssues({
|
|
65
|
+
errors: [{ path: ['a'], code: 'custom' }],
|
|
66
|
+
});
|
|
67
|
+
expect(issues).toEqual([{ path: 'a', code: 'custom' }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns [] for unrecognised errors', () => {
|
|
71
|
+
expect(formatValidationIssues(new Error('boom'))).toEqual([]);
|
|
72
|
+
expect(formatValidationIssues()).toEqual([]);
|
|
73
|
+
expect(formatValidationIssues('nope')).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('defaults a missing code and root path', () => {
|
|
77
|
+
expect(formatValidationIssues({ issues: [{}] })).toEqual([
|
|
78
|
+
{ path: '', code: 'invalid' },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('recordValidationMismatch', () => {
|
|
84
|
+
const mismatch: ValidationMismatch = {
|
|
85
|
+
name: 'POST /orders',
|
|
86
|
+
boundary: 'http',
|
|
87
|
+
mode: 'reject',
|
|
88
|
+
issues: [
|
|
89
|
+
{ path: 'a', code: 'invalid_type' },
|
|
90
|
+
{ path: 'b', code: 'too_small' },
|
|
91
|
+
{ path: 'c', code: 'invalid_type' },
|
|
92
|
+
],
|
|
93
|
+
hash: 'abc123',
|
|
94
|
+
severity: 'warning',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
it('sets validation.* attributes on the active span', () => {
|
|
98
|
+
recordValidationMismatch(mismatch);
|
|
99
|
+
expect(setAttributes).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
[VALIDATION_ATTR.name]: 'POST /orders',
|
|
102
|
+
[VALIDATION_ATTR.boundary]: 'http',
|
|
103
|
+
[VALIDATION_ATTR.mode]: 'reject',
|
|
104
|
+
[VALIDATION_ATTR.issueCount]: 3,
|
|
105
|
+
[VALIDATION_ATTR.issuePaths]: 'a,b,c',
|
|
106
|
+
[VALIDATION_ATTR.issueCodes]: 'invalid_type,too_small', // deduped
|
|
107
|
+
[VALIDATION_ATTR.hash]: 'abc123',
|
|
108
|
+
[VALIDATION_ATTR.severity]: 'warning',
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('increments the mismatch counter with boundary/validation/mode labels', () => {
|
|
114
|
+
recordValidationMismatch(mismatch);
|
|
115
|
+
expect(counterAdd).toHaveBeenCalledWith(1, {
|
|
116
|
+
boundary: 'http',
|
|
117
|
+
validation: 'POST /orders',
|
|
118
|
+
mode: 'reject',
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('skips span attributes when there is no active span (fail-open)', () => {
|
|
123
|
+
vi.spyOn(trace, 'getActiveSpan').mockReturnValue();
|
|
124
|
+
expect(() => recordValidationMismatch(mismatch)).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('never throws even if the span sink throws', () => {
|
|
128
|
+
setAttributes.mockImplementation(() => {
|
|
129
|
+
throw new Error('span boom');
|
|
130
|
+
});
|
|
131
|
+
expect(() => recordValidationMismatch(mismatch)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('onValidationMismatch', () => {
|
|
136
|
+
const mismatch = (name: string): ValidationMismatch => ({
|
|
137
|
+
name,
|
|
138
|
+
boundary: 'event',
|
|
139
|
+
mode: 'observe',
|
|
140
|
+
issues: [],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// The listener registry is module-global; track every unsubscribe so a test
|
|
144
|
+
// can't leak a subscriber into the next one.
|
|
145
|
+
const cleanups: Array<() => void> = [];
|
|
146
|
+
const register = (handler: (m: ValidationMismatch) => void) => {
|
|
147
|
+
const off = onValidationMismatch(handler);
|
|
148
|
+
cleanups.push(off);
|
|
149
|
+
return off;
|
|
150
|
+
};
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
while (cleanups.length > 0) cleanups.pop()!();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('invokes a registered listener and can unsubscribe', () => {
|
|
156
|
+
const seen: ValidationMismatch[] = [];
|
|
157
|
+
const off = register((m) => seen.push(m));
|
|
158
|
+
recordValidationMismatch(mismatch('x'));
|
|
159
|
+
expect(seen).toHaveLength(1);
|
|
160
|
+
off();
|
|
161
|
+
recordValidationMismatch(mismatch('y'));
|
|
162
|
+
expect(seen).toHaveLength(1); // not called after unsubscribe
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('delivers each mismatch to every simultaneous subscriber', () => {
|
|
166
|
+
// The real case: autotel-audit registers a security bridge while the app
|
|
167
|
+
// registers its own webhook/logger — both must fire.
|
|
168
|
+
const audit: string[] = [];
|
|
169
|
+
const webhook: string[] = [];
|
|
170
|
+
register((m) => audit.push(m.name));
|
|
171
|
+
register((m) => webhook.push(m.name));
|
|
172
|
+
|
|
173
|
+
recordValidationMismatch(mismatch('POST /login'));
|
|
174
|
+
|
|
175
|
+
expect(audit).toEqual(['POST /login']);
|
|
176
|
+
expect(webhook).toEqual(['POST /login']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('unsubscribes each subscriber independently', () => {
|
|
180
|
+
const a: string[] = [];
|
|
181
|
+
const b: string[] = [];
|
|
182
|
+
const offA = register((m) => a.push(m.name));
|
|
183
|
+
register((m) => b.push(m.name));
|
|
184
|
+
|
|
185
|
+
recordValidationMismatch(mismatch('first'));
|
|
186
|
+
offA(); // remove only A
|
|
187
|
+
recordValidationMismatch(mismatch('second'));
|
|
188
|
+
|
|
189
|
+
expect(a).toEqual(['first']); // A stopped after unsubscribe
|
|
190
|
+
expect(b).toEqual(['first', 'second']); // B keeps firing
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('isolates faults: a throwing subscriber neither throws nor starves peers', () => {
|
|
194
|
+
const survivor: string[] = [];
|
|
195
|
+
register(() => {
|
|
196
|
+
throw new Error('subscriber boom');
|
|
197
|
+
});
|
|
198
|
+
register((m) => survivor.push(m.name));
|
|
199
|
+
|
|
200
|
+
expect(() => recordValidationMismatch(mismatch('z'))).not.toThrow();
|
|
201
|
+
expect(survivor).toEqual(['z']); // the healthy subscriber still fired
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('treats a re-registered identical handler as a single subscription', () => {
|
|
205
|
+
const seen: string[] = [];
|
|
206
|
+
const handler = (m: ValidationMismatch) => seen.push(m.name);
|
|
207
|
+
register(handler);
|
|
208
|
+
register(handler); // Set semantics → still one
|
|
209
|
+
recordValidationMismatch(mismatch('once'));
|
|
210
|
+
expect(seen).toEqual(['once']);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('defineValidator', () => {
|
|
215
|
+
const ok = schema<{ a: number }>(() => ({ success: true, data: { a: 1 } }));
|
|
216
|
+
const bad = schema<{ a: number }>(() => ({
|
|
217
|
+
success: false,
|
|
218
|
+
error: zodLikeError,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
it('reject mode (default): records then throws a 400 structured error', () => {
|
|
222
|
+
const v = defineValidator('POST /orders', bad, { boundary: 'http' });
|
|
223
|
+
expect(v.mode).toBe('reject');
|
|
224
|
+
try {
|
|
225
|
+
v.parse({ user: { ssn: SECRET } });
|
|
226
|
+
throw new Error('should have thrown');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const e = error as { status?: number; code?: string; message: string };
|
|
229
|
+
expect(e.status).toBe(400);
|
|
230
|
+
expect(e.code).toBe('validation_failed');
|
|
231
|
+
// even the thrown error must not leak the value
|
|
232
|
+
expect(JSON.stringify({ m: e.message })).not.toContain(SECRET);
|
|
233
|
+
}
|
|
234
|
+
expect(setAttributes).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('observe mode: records then returns the raw input (no throw)', () => {
|
|
238
|
+
const v = defineValidator('order.placed', bad, {
|
|
239
|
+
boundary: 'event',
|
|
240
|
+
onMismatch: 'observe',
|
|
241
|
+
});
|
|
242
|
+
const raw = { user: { ssn: SECRET } };
|
|
243
|
+
expect(v.parse(raw)).toBe(raw);
|
|
244
|
+
expect(setAttributes).toHaveBeenCalledWith(
|
|
245
|
+
expect.objectContaining({ [VALIDATION_ATTR.mode]: 'observe' }),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns parsed data and records nothing on success', () => {
|
|
250
|
+
const v = defineValidator('ok', ok);
|
|
251
|
+
expect(v.parse({})).toEqual({ a: 1 });
|
|
252
|
+
expect(setAttributes).not.toHaveBeenCalled();
|
|
253
|
+
expect(counterAdd).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('safeParse returns a discriminated result and never throws', () => {
|
|
257
|
+
const v = defineValidator('POST /orders', bad);
|
|
258
|
+
const result = v.safeParse({});
|
|
259
|
+
expect(result.success).toBe(false);
|
|
260
|
+
if (!result.success) {
|
|
261
|
+
expect(result.issues[0]).toEqual({
|
|
262
|
+
path: 'user.ssn',
|
|
263
|
+
code: 'invalid_type',
|
|
264
|
+
expected: 'string',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('honors a custom onReject error builder', () => {
|
|
270
|
+
const v = defineValidator('x', bad, {
|
|
271
|
+
onReject: () => new Error('custom reject'),
|
|
272
|
+
});
|
|
273
|
+
expect(() => v.parse({})).toThrow('custom reject');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('emits a stable validation.hash when toJsonSchema is provided', () => {
|
|
277
|
+
const v = defineValidator('x', bad, {
|
|
278
|
+
toJsonSchema: () => ({ type: 'object' }),
|
|
279
|
+
});
|
|
280
|
+
v.safeParse({});
|
|
281
|
+
const attrs = setAttributes.mock.calls[0][0];
|
|
282
|
+
expect(typeof attrs[VALIDATION_ATTR.hash]).toBe('string');
|
|
283
|
+
expect(attrs[VALIDATION_ATTR.hash]).toHaveLength(64); // sha256 hex
|
|
284
|
+
});
|
|
285
|
+
});
|