gateway-metadata-bulk.test.mjs
204 lines 6.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Gateway hosted bulk metadata (canister orchestration) — fetch mocked.
3 */
4 import { describe, it, beforeEach, afterEach } from 'node:test';
5 import assert from 'node:assert';
6 import crypto from 'node:crypto';
7 import { createMetadataBulkHandlers } from '../hub/gateway/metadata-bulk-canister.mjs';
8
9 const SECRET = 'gateway-metadata-bulk-test-secret';
10 const CANISTER = 'https://mock-canister.test';
11
12 /** HS256 JWT for tests (matches `jsonwebtoken` verify in gateway handler). */
13 function bearerToken(role = 'editor') {
14 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
15 const payload = Buffer.from(JSON.stringify({ sub: 'google:test-user', role })).toString('base64url');
16 const data = `${header}.${payload}`;
17 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
18 return `${data}.${sig}`;
19 }
20
21 describe('gateway metadata-bulk-canister', () => {
22 /** @type {typeof fetch | undefined} */
23 let origFetch;
24
25 beforeEach(() => {
26 origFetch = globalThis.fetch;
27 });
28
29 afterEach(() => {
30 globalThis.fetch = origFetch;
31 });
32
33 it('deleteByProject deletes notes matching effective slug and discards proposals', async () => {
34 const notesPayload = {
35 notes: [
36 { path: 'inbox/a.md', frontmatter: '{}', body: 'A' },
37 { path: 'projects/foo/x.md', frontmatter: '{}', body: 'X' },
38 ],
39 };
40 const proposalsPayload = {
41 proposals: [
42 {
43 proposal_id: 'p1',
44 path: 'projects/foo/x.md',
45 status: 'proposed',
46 vault_id: 'default',
47 },
48 ],
49 };
50
51 globalThis.fetch = async (url, opts) => {
52 const u = String(url);
53 const method = (opts && opts.method) || 'GET';
54 if (u === `${CANISTER}/api/v1/notes` && method === 'GET') {
55 return { ok: true, status: 200, async text() { return JSON.stringify(notesPayload); } };
56 }
57 if (u === `${CANISTER}/api/v1/notes/projects%2Ffoo%2Fx.md` && method === 'DELETE') {
58 return { ok: true, status: 200, async text() { return '{"deleted":true}'; } };
59 }
60 if (u === `${CANISTER}/api/v1/proposals` && method === 'GET') {
61 return { ok: true, status: 200, async text() { return JSON.stringify(proposalsPayload); } };
62 }
63 if (u === `${CANISTER}/api/v1/proposals/p1/discard` && method === 'POST') {
64 return { ok: true, status: 200, async text() { return '{}'; } };
65 }
66 return { ok: false, status: 404, async text() { return 'unexpected ' + u; } };
67 };
68
69 const handlers = createMetadataBulkHandlers({
70 CANISTER_URL: CANISTER,
71 BRIDGE_URL: '',
72 SESSION_SECRET: SECRET,
73 getUserId: () => 'google:test-user',
74 getHostedAccessContext: async () => null,
75 });
76
77 /** @type {any} */
78 const res = {
79 statusCode: 200,
80 payload: null,
81 status(c) {
82 this.statusCode = c;
83 return this;
84 },
85 json(o) {
86 this.payload = o;
87 return this;
88 },
89 };
90
91 await handlers.deleteByProject(
92 {
93 headers: { authorization: 'Bearer ' + bearerToken('editor') },
94 body: { project: 'foo' },
95 },
96 res,
97 );
98
99 assert.strictEqual(res.statusCode, 200);
100 assert.strictEqual(res.payload.deleted, 1);
101 assert.deepStrictEqual(res.payload.paths, ['projects/foo/x.md']);
102 assert.strictEqual(res.payload.proposals_discarded, 1);
103 });
104
105 it('renameProject posts merged body for each matching note', async () => {
106 const notesPayload = {
107 notes: [
108 { path: 'inbox/o.md', frontmatter: JSON.stringify({ project: 'oldslug', title: 'T' }), body: 'B' },
109 { path: 'inbox/other.md', frontmatter: JSON.stringify({ project: 'x' }), body: 'O' },
110 ],
111 };
112 /** @type {unknown[]} */
113 const posts = [];
114
115 globalThis.fetch = async (url, opts) => {
116 const u = String(url);
117 const method = (opts && opts.method) || 'GET';
118 if (u === `${CANISTER}/api/v1/notes` && method === 'GET') {
119 return { ok: true, status: 200, async text() { return JSON.stringify(notesPayload); } };
120 }
121 if (u === `${CANISTER}/api/v1/notes` && method === 'POST') {
122 posts.push(JSON.parse(String(opts.body)));
123 return { ok: true, status: 200, async text() { return '{"written":true}'; } };
124 }
125 return { ok: false, status: 404, async text() { return ''; } };
126 };
127
128 const handlers = createMetadataBulkHandlers({
129 CANISTER_URL: CANISTER,
130 BRIDGE_URL: '',
131 SESSION_SECRET: SECRET,
132 getUserId: () => 'google:test-user',
133 getHostedAccessContext: async () => null,
134 });
135
136 /** @type {any} */
137 const res = {
138 statusCode: 200,
139 payload: null,
140 status(c) {
141 this.statusCode = c;
142 return this;
143 },
144 json(o) {
145 this.payload = o;
146 return this;
147 },
148 };
149
150 await handlers.renameProject(
151 {
152 headers: { authorization: 'Bearer ' + bearerToken('editor'), 'x-vault-id': 'default' },
153 body: { from: 'oldslug', to: 'newslug' },
154 },
155 res,
156 );
157
158 assert.strictEqual(res.statusCode, 200);
159 assert.strictEqual(res.payload.updated, 1);
160 assert.deepStrictEqual(res.payload.paths, ['inbox/o.md']);
161 assert.strictEqual(posts.length, 1);
162 const p = /** @type {Record<string, unknown>} */ (posts[0]);
163 assert.strictEqual(p.path, 'inbox/o.md');
164 assert.strictEqual(p.body, 'B');
165 assert.strictEqual(/** @type {any} */ (p.frontmatter).project, 'newslug');
166 assert.strictEqual(/** @type {any} */ (p.frontmatter).title, 'T');
167 });
168
169 it('deleteByProject returns 403 for viewer role', async () => {
170 globalThis.fetch = async () => ({ ok: true, status: 200, async text() { return '{"notes":[]}'; } });
171
172 const handlers = createMetadataBulkHandlers({
173 CANISTER_URL: CANISTER,
174 BRIDGE_URL: '',
175 SESSION_SECRET: SECRET,
176 getUserId: () => 'google:test-user',
177 getHostedAccessContext: async () => null,
178 });
179
180 /** @type {any} */
181 const res = {
182 statusCode: 200,
183 payload: null,
184 status(c) {
185 this.statusCode = c;
186 return this;
187 },
188 json(o) {
189 this.payload = o;
190 return this;
191 },
192 };
193
194 await handlers.deleteByProject(
195 {
196 headers: { authorization: 'Bearer ' + bearerToken('viewer') },
197 body: { project: 'x' },
198 },
199 res,
200 );
201
202 assert.strictEqual(res.statusCode, 403);
203 });
204 });
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