bridge-index-auto-routing-contract.test.mjs
278 lines 10.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Contract tests for the auto-routing wiring in `hub/bridge/server.mjs POST
3 * /api/v1/index` and `netlify/functions/bridge-index-background.mjs`.
4 *
5 * The full handler is too tightly coupled to canister + Netlify Blobs + live
6 * embedding to boot in a Node test, so we lock in the static wiring with
7 * source-string asserts. These tests exist because every wiring assertion
8 * here corresponds to a regression that would silently re-introduce the bug
9 * the auto-routing PR exists to prevent:
10 * - dropping the routing branch → big jobs go straight to sync and 504.
11 * - dropping the lock acquire → double-clicks double-bill DeepInfra.
12 * - dropping setLastIndexedAt → UI never shows "Last indexed" again.
13 * - dropping releaseJobLock → second background job blocked for 16 min.
14 * - background function not validating HMAC → public re-index trigger.
15 */
16
17 import { readFileSync } from 'node:fs';
18 import { fileURLToPath } from 'node:url';
19 import { dirname, join } from 'node:path';
20 import test from 'node:test';
21 import assert from 'node:assert/strict';
22
23 const root = join(dirname(fileURLToPath(import.meta.url)), '..');
24 const bridgeJs = readFileSync(join(root, 'hub/bridge/server.mjs'), 'utf8');
25 const bgFnJs = readFileSync(
26 join(root, 'netlify/functions/bridge-index-background.mjs'),
27 'utf8',
28 );
29 const bridgeNetlifyToml = readFileSync(join(root, 'deploy/bridge/netlify.toml'), 'utf8');
30
31 test('bridge imports auto-routing helpers', () => {
32 assert.match(
33 bridgeJs,
34 /from\s+['"]\.\.\/\.\.\/lib\/bridge-index-preflight-estimate\.mjs['"]/,
35 'must import from lib/bridge-index-preflight-estimate.mjs',
36 );
37 assert.match(
38 bridgeJs,
39 /from\s+['"]\.\.\/\.\.\/lib\/bridge-index-job-lock\.mjs['"]/,
40 'must import from lib/bridge-index-job-lock.mjs',
41 );
42 assert.match(
43 bridgeJs,
44 /from\s+['"]\.\.\/\.\.\/lib\/bridge-index-last-indexed\.mjs['"]/,
45 'must import from lib/bridge-index-last-indexed.mjs',
46 );
47 assert.match(
48 bridgeJs,
49 /from\s+['"]\.\.\/\.\.\/lib\/bridge-internal-hmac\.mjs['"]/,
50 'must import from lib/bridge-internal-hmac.mjs',
51 );
52 });
53
54 test('bridge exposes the routing decision step in the timer', () => {
55 assert.match(
56 bridgeJs,
57 /timer\.step\(['"]routing_decision['"]/,
58 'timer must emit a routing_decision step (post-mortem signal for sync vs background)',
59 );
60 });
61
62 test('bridge calls estimateEmbedSeconds + shouldUseBackgroundIndex inside POST /api/v1/index', () => {
63 // Both calls must appear inside a window starting at the index handler signature.
64 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
65 assert.ok(handlerStart > 0, 'POST /api/v1/index handler must exist');
66 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
67 assert.match(handlerWindow, /\bestimateEmbedSeconds\(/, 'must call estimateEmbedSeconds');
68 assert.match(handlerWindow, /\bshouldUseBackgroundIndex\(/, 'must call shouldUseBackgroundIndex');
69 });
70
71 test('bridge acquires lock + kicks off background fn when routed to background', () => {
72 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
73 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
74 assert.match(
75 handlerWindow,
76 /\bacquireJobLock\s*\(\s*req\.blobStore/,
77 'must acquire job lock against the request blob store',
78 );
79 assert.match(
80 handlerWindow,
81 /\bkickOffBackgroundIndex\s*\(\s*req\s*,/,
82 'must kick off the background function via the helper',
83 );
84 });
85
86 test('bridge returns 202 status:background OR 409 status:already_running for the background path', () => {
87 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
88 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
89 assert.match(
90 handlerWindow,
91 /res\.status\(202\)\.json\([\s\S]{0,500}status:\s*['"]background['"]/,
92 '202 response must include status:"background" so the UI can branch',
93 );
94 assert.match(
95 handlerWindow,
96 /res\.status\(409\)\.json\([\s\S]{0,500}status:\s*['"]already_running['"]/,
97 '409 response must include status:"already_running" so the UI can show "wait" toast',
98 );
99 });
100
101 test('bridge skips routing when req.bridgeInternalRequest is set (background re-entry)', () => {
102 // Without this short-circuit, the background function would re-route to itself
103 // recursively — every background invocation would kick off another background invocation.
104 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
105 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
106 assert.match(
107 handlerWindow,
108 /req\.bridgeInternalRequest\s*!=\s*null/,
109 'must check req.bridgeInternalRequest before routing',
110 );
111 assert.match(
112 handlerWindow,
113 /isInternalBackgroundRequest/,
114 'should name the local boolean so the intent is grep-able',
115 );
116 });
117
118 test('bridge persists last-indexed sidecar on the sync success path', () => {
119 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
120 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
121 assert.match(
122 handlerWindow,
123 /\bsetLastIndexedAt\s*\(\s*req\.blobStore\s*,/,
124 'must call setLastIndexedAt after a successful index so the UI line stays correct',
125 );
126 });
127
128 test('bridge releases the job lock on background-mode finish (success AND failure paths)', () => {
129 const handlerStart = bridgeJs.indexOf("app.post('/api/v1/index'");
130 const handlerWindow = bridgeJs.slice(handlerStart, handlerStart + 50000);
131 // Should appear at LEAST in: success path, empty-vault path, catch path.
132 const matches = handlerWindow.match(/\breleaseJobLock\s*\(/g) || [];
133 assert.ok(
134 matches.length >= 3,
135 `releaseJobLock should be called from at least 3 paths (success, empty, catch); found ${matches.length}`,
136 );
137 assert.match(
138 handlerWindow,
139 /releaseJobLock\([\s\S]{0,200}expectedJobId:\s*req\.bridgeInternalRequest\.jobId/,
140 'release MUST pass expectedJobId so a stale background fn cannot clobber a newer lock',
141 );
142 });
143
144 test('bridge exposes GET /api/v1/index/status for the UI status line', () => {
145 assert.match(
146 bridgeJs,
147 /app\.get\(['"]\/api\/v1\/index\/status['"]/,
148 'must expose GET /api/v1/index/status',
149 );
150 assert.match(
151 bridgeJs,
152 /\bgetLastIndexedAt\s*\(\s*req\.blobStore/,
153 'status endpoint must read the last-indexed sidecar',
154 );
155 assert.match(
156 bridgeJs,
157 /\bpeekJobLock\s*\(\s*req\.blobStore/,
158 'status endpoint must peek the job lock so the UI knows when a background job is in flight',
159 );
160 });
161
162 test('bridge kickoff helper signs request + targets the bridge-index-background function URL', () => {
163 // The fetch URL must point at the background function (not the regular bridge),
164 // otherwise the kicked-off work runs in the 60s sync function and gets killed.
165 assert.match(
166 bridgeJs,
167 /\.netlify\/functions\/bridge-index-background/,
168 'kickoff URL must hit /.netlify/functions/bridge-index-background',
169 );
170 assert.match(
171 bridgeJs,
172 /\bsignInternalRequest\s*\(\s*SESSION_SECRET/,
173 'kickoff must sign the request with SESSION_SECRET',
174 );
175 assert.match(
176 bridgeJs,
177 /'x-bridge-internal-sig'\s*:\s*sig/,
178 'kickoff must include the HMAC header so the receiver can verify',
179 );
180 });
181
182 test('background function file: validates HMAC before doing any work', () => {
183 assert.match(
184 bgFnJs,
185 /from\s+['"]\.\.\/\.\.\/lib\/bridge-internal-hmac\.mjs['"]/,
186 'must import the HMAC verifier',
187 );
188 assert.match(
189 bgFnJs,
190 /\bverifyInternalRequest\(/,
191 'must call verifyInternalRequest before invoking the Express app',
192 );
193 // Returning 401 on HMAC failure is the only safe behavior — anything else
194 // would let an attacker probe whether the secret is correct.
195 assert.match(
196 bgFnJs,
197 /statusCode:\s*401/,
198 'must return 401 on HMAC failure (the public URL must reject all unsigned callers)',
199 );
200 });
201
202 test('background function file: sets the internal-request marker globalThis.__bridge_internal_request', () => {
203 // The marker is what tells the index handler to skip the routing decision.
204 // Without it, the background fn would re-route to itself recursively.
205 assert.match(
206 bgFnJs,
207 /globalThis\.__bridge_internal_request\s*=\s*\{/,
208 'must set globalThis.__bridge_internal_request before invoking the app',
209 );
210 assert.match(
211 bgFnJs,
212 /delete\s+globalThis\.__bridge_internal_request/,
213 'must clean up the marker in finally so the next invocation starts fresh',
214 );
215 });
216
217 test('background function file: route guard rejects anything other than POST /api/v1/index', () => {
218 assert.match(
219 bgFnJs,
220 /isAllowedRoute\(/,
221 'must call a route guard before doing any auth/work',
222 );
223 assert.match(
224 bgFnJs,
225 /statusCode:\s*404/,
226 'must return 404 for non-allowed routes',
227 );
228 });
229
230 test('netlify.toml registers the bridge-index-background function', () => {
231 // Netlify only applies the 15-min background timeout when the function is named
232 // with the `-background` suffix AND the netlify.toml registers it (so its build
233 // step + external_node_modules align with the bridge function).
234 assert.match(
235 bridgeNetlifyToml,
236 /\[functions\."bridge-index-background"\]/,
237 'deploy/bridge/netlify.toml must declare the bridge-index-background function',
238 );
239 });
240
241 test('netlify.toml does NOT attempt to redirect /.netlify/* paths (Netlify rejects them)', () => {
242 // Regression guard: an earlier hotfix attempt added an explicit passthrough
243 // `[[redirects]] from = "/.netlify/functions/*"` here, which Netlify rejects
244 // at deploy time with "Invalid /.netlify path in redirect source". The
245 // namespace is auto-exempt from user redirects per
246 // docs.netlify.com/routing/redirects/redirect-options/#shadowing — adding
247 // such a rule is both unnecessary and causes a deploy validation failure.
248 //
249 // This test ensures the rule is not reintroduced.
250 const sources = [];
251 for (const m of bridgeNetlifyToml.matchAll(/from\s*=\s*"([^"]+)"/g)) {
252 sources.push(m[1]);
253 }
254 for (const src of sources) {
255 assert.ok(
256 !src.startsWith('/.netlify'),
257 `redirect source "${src}" starts with /.netlify which Netlify rejects as a reserved namespace`,
258 );
259 }
260 });
261
262 test('bridge kickoff helper validates response.status (May 2026 hotfix)', () => {
263 // Regression context: prior code did `await fetch(url, …)` and never inspected
264 // `response.status`. fetch() resolves successfully on 4xx/5xx HTTP responses
265 // (it only throws on network errors), so a 404 from the redirect-bug above was
266 // silently treated as success. Defense in depth: assert the helper imports the
267 // pure validator AND calls it after the fetch.
268 assert.match(
269 bridgeJs,
270 /from\s+['"]\.\.\/\.\.\/lib\/bridge-index-kickoff-response\.mjs['"]/,
271 'must import from lib/bridge-index-kickoff-response.mjs',
272 );
273 assert.match(
274 bridgeJs,
275 /\bassertBackgroundKickoffOk\s*\(/,
276 'kickoff helper MUST call assertBackgroundKickoffOk so a 404/5xx fails loudly',
277 );
278 });
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