gateway-task-proxy.test.mjs
197 lines 7.1 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Gateway → bridge task route proxy contract (Phase 2G hosted parity).
3 *
4 * Locks in proxy wiring so hosted gateway forwards task REST to bridge,
5 * mirroring calendar/delegation hosted parity pattern.
6 */
7
8 import fs from 'node:fs';
9 import { test } from 'node:test';
10 import assert from 'node:assert/strict';
11 import http from 'http';
12 import express from 'express';
13 import crypto from 'crypto';
14 import path from 'path';
15 import { fileURLToPath, pathToFileURL } from 'url';
16
17 const __dirname = path.dirname(fileURLToPath(import.meta.url));
18 const projectRoot = path.resolve(__dirname, '..');
19
20 const SECRET = 'gateway-task-proxy-test-secret-32chars';
21
22 function signTestJwt(payload) {
23 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
24 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
25 const data = `${header}.${body}`;
26 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
27 return `${data}.${sig}`;
28 }
29
30 function startMockBridge(mockBridge) {
31 const srv = http.createServer(mockBridge);
32 return new Promise((resolve, reject) => {
33 srv.listen(0, '127.0.0.1', (err) => {
34 if (err) return reject(err);
35 const port = srv.address().port;
36 resolve({
37 bridgeUrl: `http://127.0.0.1:${port}`,
38 close: () => new Promise((r) => srv.close(() => r())),
39 });
40 });
41 });
42 }
43
44 test('gateway proxies GET /api/v1/tasks to bridge with auth headers', async (t) => {
45 const calls = [];
46 const mockBridge = express();
47 mockBridge.get('/api/v1/tasks', (req, res) => {
48 calls.push({
49 method: req.method,
50 url: req.originalUrl,
51 auth: req.headers.authorization,
52 vault: req.headers['x-vault-id'],
53 });
54 res.status(200).json({
55 schema: 'knowtation.task_list/v0',
56 vault_id: 'default',
57 effective_scope: 'personal',
58 tasks: [{ task_id: 'task_personal_practice', scope: 'personal', kind: 'personal', status: 'pending', title: 'Practice', workspace_id: 'ws', due_at: null, run_ref: null, truncated: false }],
59 truncated: false,
60 });
61 });
62
63 const { bridgeUrl, close } = await startMockBridge(mockBridge);
64 t.after(close);
65
66 process.env.NETLIFY = '1';
67 process.env.CANISTER_URL = 'http://canister.placeholder.test';
68 process.env.SESSION_SECRET = SECRET;
69 process.env.BRIDGE_URL = bridgeUrl;
70
71 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
72 const { app: gwApp } = await import(`${gwEntry}?gwtask=${Date.now()}`);
73
74 const gwSrv = http.createServer(gwApp);
75 await new Promise((resolve, reject) => {
76 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
77 });
78 t.after(() => new Promise((r) => gwSrv.close(() => r())));
79
80 const port = gwSrv.address().port;
81 const token = signTestJwt({ sub: 'user-task-smoke', role: 'editor' });
82 const res = await fetch(`http://127.0.0.1:${port}/api/v1/tasks`, {
83 method: 'GET',
84 headers: {
85 authorization: `Bearer ${token}`,
86 'x-vault-id': 'default',
87 },
88 });
89 assert.equal(res.status, 200);
90 const json = await res.json();
91 assert.equal(json.schema, 'knowtation.task_list/v0');
92 assert.equal(calls.length, 1);
93 assert.equal(calls[0].method, 'GET');
94 assert.match(calls[0].auth, /^Bearer /);
95 assert.equal(calls[0].vault, 'default');
96 });
97
98 test('gateway proxies POST /api/v1/tasks/proposals to bridge', async (t) => {
99 const calls = [];
100 const mockBridge = express();
101 mockBridge.use(express.json());
102 mockBridge.post('/api/v1/tasks/proposals', (req, res) => {
103 calls.push({ method: req.method, body: req.body, vault: req.headers['x-vault-id'] });
104 res.status(403).json({ error: 'Task writes are disabled', code: 'TASK_WRITES_DISABLED' });
105 });
106
107 const { bridgeUrl, close } = await startMockBridge(mockBridge);
108 t.after(close);
109
110 process.env.NETLIFY = '1';
111 process.env.CANISTER_URL = 'http://canister.placeholder.test';
112 process.env.SESSION_SECRET = SECRET;
113 process.env.BRIDGE_URL = bridgeUrl;
114
115 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
116 const { app: gwApp } = await import(`${gwEntry}?gwtaskpost=${Date.now()}`);
117
118 const gwSrv = http.createServer(gwApp);
119 await new Promise((resolve, reject) => {
120 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
121 });
122 t.after(() => new Promise((r) => gwSrv.close(() => r())));
123
124 const port = gwSrv.address().port;
125 const token = signTestJwt({ sub: 'user-task-write', role: 'editor' });
126 const res = await fetch(`http://127.0.0.1:${port}/api/v1/tasks/proposals`, {
127 method: 'POST',
128 headers: {
129 authorization: `Bearer ${token}`,
130 'content-type': 'application/json',
131 'x-vault-id': 'default',
132 },
133 body: JSON.stringify({ proposal_kind: 'task_create', intent: 'test', task: { task_id: 'task_x' } }),
134 });
135 assert.equal(res.status, 403);
136 const json = await res.json();
137 assert.equal(json.code, 'TASK_WRITES_DISABLED');
138 assert.equal(calls.length, 1);
139 assert.equal(calls[0].method, 'POST');
140 });
141
142 test('gateway proxies GET /api/v1/task-loops to bridge', async (t) => {
143 const calls = [];
144 const mockBridge = express();
145 mockBridge.get('/api/v1/task-loops', (req, res) => {
146 calls.push({ method: req.method, vault: req.headers['x-vault-id'] });
147 res.status(200).json({
148 schema: 'knowtation.task_loop_list/v0',
149 vault_id: 'default',
150 effective_scope: 'personal',
151 loops: [{ loop_id: 'loop_school_trip', scope: 'personal', status: 'active', title: 't', truncated: false }],
152 truncated: false,
153 });
154 });
155
156 const { bridgeUrl, close } = await startMockBridge(mockBridge);
157 t.after(close);
158
159 process.env.NETLIFY = '1';
160 process.env.CANISTER_URL = 'http://canister.placeholder.test';
161 process.env.SESSION_SECRET = SECRET;
162 process.env.BRIDGE_URL = bridgeUrl;
163
164 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
165 const { app: gwApp } = await import(`${gwEntry}?gwloop=${Date.now()}`);
166
167 const gwSrv = http.createServer(gwApp);
168 await new Promise((resolve, reject) => {
169 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
170 });
171 t.after(() => new Promise((r) => gwSrv.close(() => r())));
172
173 const port = gwSrv.address().port;
174 const token = signTestJwt({ sub: 'user-loop-smoke', role: 'editor' });
175 const res = await fetch(`http://127.0.0.1:${port}/api/v1/task-loops`, {
176 method: 'GET',
177 headers: {
178 authorization: `Bearer ${token}`,
179 'x-vault-id': 'default',
180 },
181 });
182 assert.equal(res.status, 200);
183 const json = await res.json();
184 assert.equal(json.schema, 'knowtation.task_loop_list/v0');
185 assert.equal(calls.length, 1);
186 assert.equal(calls[0].method, 'GET');
187 });
188
189 test('gateway source registers task + loop proxy routes', () => {
190 const src = fs.readFileSync(path.join(projectRoot, 'hub', 'gateway', 'server.mjs'), 'utf8');
191 assert.match(src, /Task routes \(hosted parity — 2G\)/);
192 assert.match(src, /\/api\/v1\/tasks/);
193 assert.match(src, /\/api\/v1\/task-loops/);
194 assert.match(src, /\/api\/v1\/loop-pass-audit/);
195 assert.match(src, /\/api\/v1\/tasks\/proposals/);
196 assert.match(src, /maybeApplyHostedTaskAfterApprove/);
197 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago