task-bridge-hosted-route.test.mjs
219 lines 9.3 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Hosted bridge task route contract tests (Phase 2G hosted parity).
3 *
4 * Tiers: unit, integration, e2e, stress, data-integrity, performance, security.
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11
12 import { bridgeTaskHandlerRole } from '../hub/bridge/task-routes.mjs';
13 import { resolveStarterTasksDir } from '../lib/task/task-store.mjs';
14 import {
15 mergeTaskFrontmatter,
16 normalizeCanisterProposalForTaskPrecheck,
17 FM_PROPOSAL_SOURCE,
18 FM_TASK_PROPOSAL_KIND,
19 } from '../lib/task/task-hosted-proposal.mjs';
20 import { TASK_PROPOSAL_SOURCE } from '../lib/task/task-write.mjs';
21 import { handleTaskListRequest } from '../lib/task/task-handlers.mjs';
22
23 const __dirname = path.dirname(fileURLToPath(import.meta.url));
24 const repoRoot = path.dirname(__dirname);
25
26 function readRepoFile(relativePath) {
27 return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
28 }
29
30 describe('hosted bridge task routes — unit', () => {
31 it('bridgeTaskHandlerRole maps member to editor (self-hosted parity)', () => {
32 assert.equal(bridgeTaskHandlerRole('member'), 'editor');
33 assert.equal(bridgeTaskHandlerRole('admin'), 'admin');
34 assert.equal(bridgeTaskHandlerRole('viewer'), 'viewer');
35 });
36
37 it('resolveStarterTasksDir finds bundled tasks/starter from bridge module', () => {
38 const starterDir = resolveStarterTasksDir(new URL('../hub/bridge/task-routes.mjs', import.meta.url).href);
39 assert.ok(fs.existsSync(starterDir), `starter dir missing: ${starterDir}`);
40 const files = fs.readdirSync(starterDir).filter((f) => f.startsWith('task_') && f.endsWith('.json'));
41 assert.ok(files.length >= 1);
42 });
43
44 it('resolveStarterTaskLoopsDir finds bundled task-loops/starter from bridge module', async () => {
45 const { resolveStarterTaskLoopsDir } = await import('../lib/task/task-loop-store.mjs');
46 const loopDir = resolveStarterTaskLoopsDir(
47 new URL('../hub/bridge/task-routes.mjs', import.meta.url).href,
48 );
49 assert.ok(fs.existsSync(loopDir), `loop starter dir missing: ${loopDir}`);
50 const files = fs.readdirSync(loopDir).filter((f) => f.startsWith('loop_') && f.endsWith('.json'));
51 assert.ok(files.length >= 1);
52 });
53
54 it('deploy/bridge netlify.toml includes task + loop starters in bridge function bundle', () => {
55 const bridgeToml = readRepoFile('deploy/bridge/netlify.toml');
56 const rootToml = readRepoFile('netlify.toml');
57 assert.match(bridgeToml, /included_files\s*=\s*\[/);
58 assert.match(bridgeToml, /tasks\/starter/);
59 assert.match(bridgeToml, /task-loops\/starter/);
60 assert.match(rootToml, /task-loops\/starter/);
61 });
62
63 it('mergeTaskFrontmatter embeds task proposal source and kind', () => {
64 const fm = mergeTaskFrontmatter({}, {
65 record_kind: 'task',
66 proposal_kind: 'task_create',
67 task_id: 'task_smoke_001',
68 });
69 assert.equal(fm[FM_PROPOSAL_SOURCE], TASK_PROPOSAL_SOURCE);
70 assert.equal(fm[FM_TASK_PROPOSAL_KIND], 'task_create');
71 assert.equal(fm.task_id, 'task_smoke_001');
72 });
73
74 it('normalizeCanisterProposalForTaskPrecheck maps frontmatter to task_meta', () => {
75 const fm = mergeTaskFrontmatter({}, {
76 record_kind: 'task',
77 proposal_kind: 'task_create',
78 task_id: 'task_abc',
79 });
80 const normalized = normalizeCanisterProposalForTaskPrecheck({
81 proposal_id: 'prop-1',
82 status: 'proposed',
83 vault_id: 'Business',
84 body: '{"proposal_kind":"task_create","task":{}}',
85 frontmatter: JSON.stringify(fm),
86 });
87 assert.ok(normalized);
88 assert.equal(normalized.source, TASK_PROPOSAL_SOURCE);
89 assert.equal(normalized.task_meta.proposal_kind, 'task_create');
90 assert.equal(normalized.task_meta.task_id, 'task_abc');
91 });
92 });
93
94 describe('hosted bridge task routes — integration', () => {
95 it('registers GET list/get and POST propose routes on bridge + gateway proxy', () => {
96 const bridge = readRepoFile('hub/bridge/task-routes.mjs');
97 const gateway = readRepoFile('hub/gateway/server.mjs');
98 const bridgeServer = readRepoFile('hub/bridge/server.mjs');
99
100 assert.match(bridge, /app\.get\('\/api\/v1\/tasks', requireBridgeAuth/);
101 assert.match(bridge, /app\.get\('\/api\/v1\/tasks\/:id', requireBridgeAuth/);
102 assert.match(bridge, /app\.get\('\/api\/v1\/task-loops', requireBridgeAuth/);
103 assert.match(bridge, /app\.get\('\/api\/v1\/task-loops\/:loop_id', requireBridgeAuth/);
104 assert.match(bridge, /app\.post\('\/api\/v1\/loop-pass-audit', requireBridgeAuth/);
105 assert.match(bridge, /app\.post\('\/api\/v1\/tasks\/proposals', requireBridgeAuth/);
106 assert.match(bridge, /app\.post\('\/api\/v1\/task-loops\/proposals', requireBridgeAuth/);
107 assert.match(bridge, /createTaskProposalOnCanister/);
108 assert.match(bridge, /applyApprovedTaskProposalFromCanister/);
109 assert.match(bridgeServer, /registerBridgeTaskRoutes/);
110
111 assert.match(gateway, /app\.get\('\/api\/v1\/tasks'/);
112 assert.match(gateway, /app\.get\('\/api\/v1\/tasks\/:id'/);
113 assert.match(gateway, /app\.get\('\/api\/v1\/task-loops'/);
114 assert.match(gateway, /app\.get\('\/api\/v1\/task-loops\/:loop_id'/);
115 assert.match(gateway, /app\.post\('\/api\/v1\/loop-pass-audit'/);
116 assert.match(gateway, /app\.post\('\/api\/v1\/tasks\/proposals'/);
117 assert.match(gateway, /app\.post\('\/api\/v1\/task-loops\/proposals'/);
118 assert.match(gateway, /maybeApplyHostedTaskAfterApprove/);
119 });
120
121 it('bridge task routes reuse shared handlers from lib/task', () => {
122 const bridge = readRepoFile('hub/bridge/task-routes.mjs');
123 assert.match(bridge, /handleTaskListRequest/);
124 assert.match(bridge, /handleTaskGetRequest/);
125 assert.match(bridge, /handleTaskLoopListRequest/);
126 assert.match(bridge, /handleTaskLoopGetRequest/);
127 assert.match(bridge, /handleLoopPassAuditAppendRequest/);
128 assert.match(bridge, /resolveStarterTasksDir/);
129 assert.match(bridge, /BRIDGE_STARTER_TASKS_DIR/);
130 assert.match(bridge, /await handleTaskProposeRequest/);
131 assert.match(bridge, /resolveHostedBridgeContext/);
132 });
133 });
134
135 describe('hosted bridge task routes — e2e (handler + store)', () => {
136 const dataDir = path.join(repoRoot, 'test/fixtures/tmp-task-bridge-hosted/data');
137
138 beforeEach(() => {
139 fs.rmSync(dataDir, { recursive: true, force: true });
140 fs.mkdirSync(dataDir, { recursive: true });
141 });
142
143 afterEach(() => {
144 fs.rmSync(dataDir, { recursive: true, force: true });
145 });
146
147 it('listTasks lazy-seeds starters on empty vault', () => {
148 const starterDir = resolveStarterTasksDir(new URL('../hub/bridge/task-routes.mjs', import.meta.url).href);
149 const result = handleTaskListRequest({
150 dataDir,
151 vaultId: 'default',
152 userId: 'user-test',
153 role: 'admin',
154 starterDir,
155 });
156 assert.equal(result.ok, true);
157 assert.equal(result.payload.schema, 'knowtation.task_list/v0');
158 assert.ok(result.payload.tasks.length >= 1);
159 });
160 });
161
162 describe('hosted bridge task routes — stress', () => {
163 it('gateway source lists all task proxy paths without duplicate mounts', () => {
164 const gateway = readRepoFile('hub/gateway/server.mjs');
165 const matches = gateway.match(/app\.get\('\/api\/v1\/tasks'/g) ?? [];
166 assert.equal(matches.length, 1);
167 const postMatches = gateway.match(/app\.post\('\/api\/v1\/tasks\/proposals'/g) ?? [];
168 assert.equal(postMatches.length, 1);
169 });
170 });
171
172 describe('hosted bridge task routes — data-integrity', () => {
173 it('normalize rejects non-task proposals', () => {
174 const normalized = normalizeCanisterProposalForTaskPrecheck({
175 proposal_id: 'prop-x',
176 path: 'inbox/note.md',
177 body: '{}',
178 frontmatter: '{}',
179 });
180 assert.equal(normalized, null);
181 });
182
183 it('accepts task proposals by meta/tasks path prefix', () => {
184 const normalized = normalizeCanisterProposalForTaskPrecheck({
185 proposal_id: 'prop-y',
186 path: 'meta/tasks/proposals/prop-y.json',
187 body: '{"proposal_kind":"task_create","task":{"task_id":"task_x","kind":"personal","scope":"personal","status":"pending","title":"t","workspace_id":"ws","due_at":null,"artifact_links":[],"truncated":false}}',
188 frontmatter: '{}',
189 base_state_id: 'taskst1_abc',
190 });
191 assert.ok(normalized);
192 assert.equal(normalized.task_meta.proposal_kind, 'task_create');
193 });
194 });
195
196 describe('hosted bridge task routes — performance', () => {
197 it('bridge routes module stays under reasonable import surface', () => {
198 const bridge = readRepoFile('hub/bridge/task-routes.mjs');
199 assert.ok(bridge.length < 16000, 'task-routes.mjs should remain a thin registrar');
200 });
201 });
202
203 describe('hosted bridge task routes — security', () => {
204 it('bridge task routes require auth before hosted context resolution', () => {
205 const bridge = readRepoFile('hub/bridge/task-routes.mjs');
206 assert.match(bridge, /requireBridgeAuth/);
207 assert.match(bridge, /resolveHostedBridgeContext/);
208 assert.match(bridge, /taskHandlerContext/);
209 assert.doesNotMatch(bridge, /oauth_ref|refresh_token/);
210 });
211
212 it('propose routes gate through TASK_WRITES_ENABLED via shared handler', () => {
213 const bridge = readRepoFile('hub/bridge/task-routes.mjs');
214 assert.match(bridge, /resolveStarterTasksDir/);
215 assert.match(bridge, /BRIDGE_STARTER_TASKS_DIR/);
216 assert.match(bridge, /await handleTaskProposeRequest/);
217 assert.doesNotMatch(bridge, /TASK_WRITES_ENABLED\s*=\s*true/);
218 });
219 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago