bridge-index-auto-routing-contract.test.mjs
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