companion-loopback-guard-data-integrity.test.mjs
129 lines 5.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tier 5 — DATA-INTEGRITY: purity, determinism, and no-mutation guarantees.
3 *
4 * The guard is a decision authority; its trustworthiness depends on being a PURE function of its
5 * inputs. This tier proves: identical inputs → identical verdict; inputs are never mutated; the
6 * verdict shape is invariant; reason codes come only from the frozen constant set; and the
7 * rate-state helpers produce new state without aliasing the input.
8 *
9 * Reference: docs/COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md (purity / fail-closed invariants).
10 */
11 import { describe, it } from 'node:test';
12 import assert from 'node:assert/strict';
13 import {
14 verifyLoopbackRequest,
15 createLoopbackRateState,
16 recordLoopbackRequest,
17 LOOPBACK_GUARD_REASONS,
18 } from '../lib/companion-loopback-guard.mjs';
19
20 const PORT = '53550';
21 const TOKEN = 'integ-' + 'f'.repeat(40);
22 const ALLOWED_HOSTS = [`127.0.0.1:${PORT}`, `localhost:${PORT}`];
23 const REASON_VALUES = new Set(Object.values(LOOPBACK_GUARD_REASONS));
24
25 function base(overrides = {}) {
26 return {
27 method: 'POST',
28 headers: { Host: `127.0.0.1:${PORT}`, Origin: `http://127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'same-origin' },
29 token: TOKEN,
30 expectedToken: TOKEN,
31 allowedHosts: ALLOWED_HOSTS,
32 now: 1000,
33 rateState: createLoopbackRateState(),
34 ...overrides,
35 };
36 }
37
38 describe('Data-integrity — determinism', () => {
39 it('identical inputs produce identical verdicts across 10k calls', () => {
40 const input = base();
41 const first = verifyLoopbackRequest(input);
42 for (let i = 0; i < 10_000; i++) {
43 assert.deepEqual(verifyLoopbackRequest(input), first);
44 }
45 });
46
47 it('each distinct rejection class is stable', () => {
48 const cases = [
49 base({ method: 'DELETE' }),
50 base({ headers: { Host: 'bad.example' } }),
51 base({ headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'cross-site' } }),
52 base({ token: 'x' }),
53 base({ rateState: { windowMs: 1000, maxRequests: 1, timestamps: [1000] } }),
54 ];
55 for (const c of cases) {
56 const a = verifyLoopbackRequest(c);
57 const b = verifyLoopbackRequest(c);
58 assert.deepEqual(a, b);
59 }
60 });
61 });
62
63 describe('Data-integrity — no input mutation', () => {
64 it('verifyLoopbackRequest does not mutate headers, allowedHosts, or rateState', () => {
65 const headers = { Host: `127.0.0.1:${PORT}`, Origin: `http://127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'same-origin' };
66 const allowedHosts = [`127.0.0.1:${PORT}`];
67 const rateState = createLoopbackRateState({ windowMs: 1000, maxRequests: 5 });
68 const headersSnapshot = JSON.stringify(headers);
69 const hostsSnapshot = JSON.stringify(allowedHosts);
70 const rateSnapshot = JSON.stringify(rateState);
71
72 verifyLoopbackRequest({ method: 'POST', headers, token: TOKEN, expectedToken: TOKEN, allowedHosts, now: 1, rateState });
73
74 assert.equal(JSON.stringify(headers), headersSnapshot);
75 assert.equal(JSON.stringify(allowedHosts), hostsSnapshot);
76 assert.equal(JSON.stringify(rateState), rateSnapshot);
77 });
78
79 it('recordLoopbackRequest returns a new object and leaves the input untouched', () => {
80 const s0 = createLoopbackRateState({ windowMs: 1000, maxRequests: 5 });
81 const before = JSON.stringify(s0);
82 const s1 = recordLoopbackRequest(s0, 10);
83 assert.notEqual(s1, s0, 'must be a new reference');
84 assert.notEqual(s1.timestamps, s0.timestamps, 'timestamps array must be a new array');
85 assert.equal(JSON.stringify(s0), before, 'input not mutated');
86 });
87 });
88
89 describe('Data-integrity — verdict shape and reason domain', () => {
90 it('every verdict has exactly { allow, status, reason } with valid types', () => {
91 const inputs = [
92 base(),
93 base({ method: 'PUT' }),
94 base({ headers: { Host: 'bad' } }),
95 base({ token: undefined }),
96 base({ rateState: undefined }),
97 ];
98 for (const inp of inputs) {
99 const v = verifyLoopbackRequest(inp);
100 assert.deepEqual(Object.keys(v).sort(), ['allow', 'reason', 'status']);
101 assert.equal(typeof v.allow, 'boolean');
102 assert.ok([200, 401, 403, 429].includes(v.status));
103 assert.ok(REASON_VALUES.has(v.reason), `reason ${v.reason} must be a known constant`);
104 }
105 });
106
107 it('allow===true iff status===200 iff reason===ok', () => {
108 const inputs = [base(), base({ token: 'bad' }), base({ method: 'PUT' }), base({ rateState: undefined })];
109 for (const inp of inputs) {
110 const v = verifyLoopbackRequest(inp);
111 assert.equal(v.allow, v.status === 200);
112 assert.equal(v.allow, v.reason === LOOPBACK_GUARD_REASONS.OK);
113 }
114 });
115
116 it('LOOPBACK_GUARD_REASONS is frozen', () => {
117 assert.equal(Object.isFrozen(LOOPBACK_GUARD_REASONS), true);
118 });
119 });
120
121 describe('Data-integrity — no global / env state read', () => {
122 it('produces the same verdict regardless of surrounding env vars', () => {
123 const v1 = verifyLoopbackRequest(base());
124 process.env.KNOWTATION_FAKE_TEST_VAR = 'tampered';
125 const v2 = verifyLoopbackRequest(base());
126 delete process.env.KNOWTATION_FAKE_TEST_VAR;
127 assert.deepEqual(v1, v2);
128 });
129 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago