gateway-index-status-proxy.test.mjs
157 lines 6.1 KB
Raw
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor ⚠ breaking 16 days ago
1 /**
2 * Gateway → bridge `/api/v1/index/status` proxy contract.
3 *
4 * Why this file exists (regression context, May 2026):
5 * PR #205 added `GET /api/v1/index/status` to the bridge so the Hub UI could
6 * render `Last indexed: N minutes ago` next to the Re-index button. The
7 * bridge route was implemented and tested in
8 * `test/bridge-index-auto-routing-contract.test.mjs`, but the gateway was
9 * never updated to forward this NEW path to the bridge — Express returned
10 * 404 to the browser. The UI line stayed empty even on vaults that had
11 * successfully written the sidecar.
12 *
13 * This test locks in the proxy wiring so the same regression can't recur:
14 * if anyone removes the gateway handler, this test fails before deploy
15 * instead of silently breaking the UI line again.
16 *
17 * Test pattern mirrors `test/gateway-memory-bridge-proxy.test.mjs`.
18 */
19
20 import { test } from 'node:test';
21 import assert from 'node:assert/strict';
22 import http from 'http';
23 import express from 'express';
24 import crypto from 'crypto';
25 import path from 'path';
26 import { fileURLToPath, pathToFileURL } from 'url';
27
28 const __dirname = path.dirname(fileURLToPath(import.meta.url));
29 const projectRoot = path.resolve(__dirname, '..');
30
31 const SECRET = 'gateway-index-status-proxy-test-secret-32';
32
33 function signTestJwt(payload) {
34 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
35 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
36 const data = `${header}.${body}`;
37 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
38 return `${data}.${sig}`;
39 }
40
41 function startMockBridge(mockBridge) {
42 const srv = http.createServer(mockBridge);
43 return new Promise((resolve, reject) => {
44 srv.listen(0, '127.0.0.1', (err) => {
45 if (err) return reject(err);
46 const port = srv.address().port;
47 resolve({
48 bridgeUrl: `http://127.0.0.1:${port}`,
49 close: () => new Promise((r) => srv.close(() => r())),
50 });
51 });
52 });
53 }
54
55 test('gateway proxies GET /api/v1/index/status to bridge with auth + vault headers', async (t) => {
56 const calls = [];
57 const mockBridge = express();
58 mockBridge.get('/api/v1/index/status', (req, res) => {
59 calls.push({
60 method: req.method,
61 url: req.originalUrl,
62 auth: req.headers.authorization,
63 vault: req.headers['x-vault-id'],
64 });
65 res.json({
66 lastIndexed: { lastIndexedAtEpochMs: 1735689600000, chunksIndexed: 251, mode: 'sync' },
67 inProgress: false,
68 job: null,
69 });
70 });
71
72 const { bridgeUrl, close } = await startMockBridge(mockBridge);
73 t.after(close);
74
75 process.env.NETLIFY = '1';
76 process.env.CANISTER_URL = 'http://canister.placeholder.test';
77 process.env.SESSION_SECRET = SECRET;
78 process.env.BRIDGE_URL = bridgeUrl;
79
80 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
81 const { app: gwApp } = await import(`${gwEntry}?gwidxstatus=${Date.now()}`);
82
83 const gwSrv = http.createServer(gwApp);
84 await new Promise((resolve, reject) => {
85 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
86 });
87 t.after(() => new Promise((r) => gwSrv.close(() => r())));
88 const gwPort = gwSrv.address().port;
89
90 const token = signTestJwt({ sub: 'google:idx-status-test', role: 'editor' });
91 const res = await fetch(`http://127.0.0.1:${gwPort}/api/v1/index/status`, {
92 headers: {
93 Authorization: `Bearer ${token}`,
94 'X-Vault-Id': 'Business',
95 },
96 });
97 const text = await res.text();
98 assert.equal(res.status, 200, `expected 200, got ${res.status}: ${text}`);
99 assert.equal(calls.length, 1, 'bridge must be called exactly once');
100 assert.equal(calls[0].method, 'GET');
101 assert.equal(
102 calls[0].url,
103 '/api/v1/index/status',
104 'gateway must preserve the path when proxying',
105 );
106 assert.equal(calls[0].auth, `Bearer ${token}`, 'Authorization header must be forwarded');
107 assert.equal(calls[0].vault, 'Business', 'X-Vault-Id header must be forwarded');
108
109 const body = JSON.parse(text);
110 assert.equal(body.inProgress, false, 'response body must be passed through');
111 assert.ok(body.lastIndexed, 'lastIndexed must be passed through');
112 });
113
114 test('gateway does NOT bill/charge for GET /api/v1/index/status (read-only sidecar)', async (t) => {
115 // The sidecar read is a passive UI status check — it MUST NOT trigger the
116 // billing gate that POST /api/v1/index uses. Otherwise every Hub page load
117 // would charge the user 50¢ for an "index" operation that did nothing.
118 // We assert this by reading from a mock bridge and confirming the request
119 // succeeds without a paid-tier user account being required.
120 const calls = [];
121 const mockBridge = express();
122 mockBridge.get('/api/v1/index/status', (_req, res) => {
123 calls.push(1);
124 res.json({ lastIndexed: null, inProgress: false, job: null });
125 });
126
127 const { bridgeUrl, close } = await startMockBridge(mockBridge);
128 t.after(close);
129
130 process.env.NETLIFY = '1';
131 process.env.CANISTER_URL = 'http://canister.placeholder.test';
132 process.env.SESSION_SECRET = SECRET;
133 process.env.BRIDGE_URL = bridgeUrl;
134
135 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
136 const { app: gwApp } = await import(`${gwEntry}?gwidxstatusbill=${Date.now()}`);
137
138 const gwSrv = http.createServer(gwApp);
139 await new Promise((resolve, reject) => {
140 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
141 });
142 t.after(() => new Promise((r) => gwSrv.close(() => r())));
143 const gwPort = gwSrv.address().port;
144
145 const token = signTestJwt({ sub: 'google:idx-status-billing-test', role: 'viewer' });
146 const res = await fetch(`http://127.0.0.1:${gwPort}/api/v1/index/status`, {
147 headers: {
148 Authorization: `Bearer ${token}`,
149 'X-Vault-Id': 'Business',
150 },
151 });
152
153 // Even a viewer (no billing entitlement to write) can READ status. The
154 // bridge is the source of truth for auth scoping; the gateway just forwards.
155 assert.equal(res.status, 200, 'viewer must still be able to read index status');
156 assert.equal(calls.length, 1, 'request must reach the bridge (no billing block)');
157 });
File History 2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 16 days ago