gateway-hosted-notes-frontmatter.test.mjs
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠ breaking
7 hours ago
| 1 | /** |
| 2 | * Hosted gateway note frontmatter normalization. |
| 3 | * |
| 4 | * These tests exercise the real gateway proxy with mocked canister and bridge |
| 5 | * responses so direct reads, list rows, facets, and hosted search stay aligned. |
| 6 | */ |
| 7 | import { describe, it, before, after } from 'node:test'; |
| 8 | import assert from 'node:assert/strict'; |
| 9 | import http from 'node:http'; |
| 10 | import crypto from 'node:crypto'; |
| 11 | import path from 'node:path'; |
| 12 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 13 | |
| 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 15 | const projectRoot = path.resolve(__dirname, '..'); |
| 16 | const SECRET = 'gateway-hosted-notes-frontmatter-secret-32'; |
| 17 | const GATEWAY_AUTH_SECRET = 'gateway-hosted-notes-frontmatter-gw-secret'; |
| 18 | const PROPOSAL_PATH = |
| 19 | 'projects/born-free/scripts/proposals/2026-06-06-quantum-computing-online-test-script.md'; |
| 20 | const PROPOSAL_TAGS = [ |
| 21 | 'script-proposal', |
| 22 | 'born-free', |
| 23 | 'quantum-ai', |
| 24 | 'human-review', |
| 25 | 'freshness-gate', |
| 26 | 'ready-for-videofactory-handoff', |
| 27 | ]; |
| 28 | const PROPOSAL_FRONTMATTER = { |
| 29 | title: 'Quantum Computing Online Test Script', |
| 30 | project: 'born-free', |
| 31 | status: 'approved', |
| 32 | tags: PROPOSAL_TAGS, |
| 33 | handoff_target: 'videofactory', |
| 34 | handoff_status: 'ready', |
| 35 | }; |
| 36 | |
| 37 | function bearer(role = 'editor') { |
| 38 | const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); |
| 39 | const payload = Buffer.from(JSON.stringify({ sub: 'google:actor', role })).toString('base64url'); |
| 40 | const data = `${header}.${payload}`; |
| 41 | const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url'); |
| 42 | return `${data}.${sig}`; |
| 43 | } |
| 44 | |
| 45 | function responseJson(status, payload) { |
| 46 | const text = JSON.stringify(payload); |
| 47 | return { |
| 48 | ok: status >= 200 && status < 300, |
| 49 | status, |
| 50 | headers: new Headers({ 'content-type': 'application/json', etag: 'mock-etag' }), |
| 51 | text: async () => text, |
| 52 | json: async () => payload, |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function installFetchMock(calls) { |
| 57 | const notes = new Map([ |
| 58 | [ |
| 59 | 'inbox/object-frontmatter.md', |
| 60 | { |
| 61 | path: 'inbox/object-frontmatter.md', |
| 62 | frontmatter: { |
| 63 | title: 'Object Frontmatter', |
| 64 | project: 'object-project', |
| 65 | status: 'draft', |
| 66 | tags: ['object-tag'], |
| 67 | }, |
| 68 | body: 'Object-shaped hosted note body.', |
| 69 | }, |
| 70 | ], |
| 71 | [ |
| 72 | 'inbox/string-frontmatter.md', |
| 73 | { |
| 74 | path: 'inbox/string-frontmatter.md', |
| 75 | frontmatter: JSON.stringify({ |
| 76 | title: 'String Frontmatter', |
| 77 | project: 'string-project', |
| 78 | status: 'draft', |
| 79 | tags: ['string-tag'], |
| 80 | }), |
| 81 | body: 'String-shaped hosted note body.', |
| 82 | }, |
| 83 | ], |
| 84 | [ |
| 85 | PROPOSAL_PATH, |
| 86 | { |
| 87 | path: PROPOSAL_PATH, |
| 88 | frontmatter: JSON.stringify(PROPOSAL_FRONTMATTER), |
| 89 | body: '# Quantum Computing Online Test Script\n\nCanonical approved script proposal.', |
| 90 | }, |
| 91 | ], |
| 92 | ]); |
| 93 | |
| 94 | globalThis.fetch = async (url, opts = {}) => { |
| 95 | const method = opts.method || 'GET'; |
| 96 | const u = new URL(String(url)); |
| 97 | calls.push({ url: String(url), method, headers: opts.headers }); |
| 98 | |
| 99 | if (u.origin === 'https://mock-bridge.test' && u.pathname === '/api/v1/hosted-context') { |
| 100 | return responseJson(200, { |
| 101 | actor_sub: 'google:actor', |
| 102 | effective_canister_user_id: 'google:owner', |
| 103 | allowed_vault_ids: ['default'], |
| 104 | role: 'editor', |
| 105 | scope: null, |
| 106 | }); |
| 107 | } |
| 108 | |
| 109 | if (u.origin === 'https://mock-bridge.test' && u.pathname === '/api/v1/search') { |
| 110 | return responseJson(200, { |
| 111 | results: [ |
| 112 | { |
| 113 | path: PROPOSAL_PATH, |
| 114 | project: PROPOSAL_FRONTMATTER.project, |
| 115 | tags: PROPOSAL_TAGS, |
| 116 | score: 1, |
| 117 | }, |
| 118 | ], |
| 119 | query: 'ready-for-videofactory-handoff', |
| 120 | mode: 'keyword', |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | if (u.origin === 'https://mock-canister.test' && u.pathname === '/api/v1/notes' && method === 'GET') { |
| 125 | return responseJson(200, { notes: [...notes.values()], total: notes.size }); |
| 126 | } |
| 127 | |
| 128 | if (u.origin === 'https://mock-canister.test' && u.pathname.startsWith('/api/v1/notes/') && method === 'GET') { |
| 129 | const encoded = u.pathname.slice('/api/v1/notes/'.length); |
| 130 | const notePath = decodeURIComponent(encoded); |
| 131 | const note = notes.get(notePath); |
| 132 | if (!note) return responseJson(404, { error: 'Not found', code: 'NOT_FOUND' }); |
| 133 | return responseJson(200, note); |
| 134 | } |
| 135 | |
| 136 | return responseJson(500, { error: `unexpected ${method} ${u.href}` }); |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | describe('hosted gateway note frontmatter normalization', () => { |
| 141 | /** @type {import('http').Server} */ |
| 142 | let server; |
| 143 | /** @type {string} */ |
| 144 | let base; |
| 145 | /** @type {typeof fetch} */ |
| 146 | let origFetch; |
| 147 | /** @type {Array<{ url: string, method: string, headers?: HeadersInit }>} */ |
| 148 | let calls; |
| 149 | |
| 150 | before(async () => { |
| 151 | origFetch = globalThis.fetch.bind(globalThis); |
| 152 | process.env.NETLIFY = '1'; |
| 153 | process.env.CANISTER_URL = 'https://mock-canister.test'; |
| 154 | process.env.BRIDGE_URL = 'https://mock-bridge.test'; |
| 155 | process.env.SESSION_SECRET = SECRET; |
| 156 | process.env.CANISTER_AUTH_SECRET = GATEWAY_AUTH_SECRET; |
| 157 | process.env.BILLING_ENFORCE = 'false'; |
| 158 | delete process.env.KNOWTATION_AIR_ENDPOINT; |
| 159 | |
| 160 | calls = []; |
| 161 | installFetchMock(calls); |
| 162 | |
| 163 | const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href; |
| 164 | const { app } = await import(`${gwEntry}?hostednotesfrontmatter=${Date.now()}-${Math.random()}`); |
| 165 | server = http.createServer(app); |
| 166 | await new Promise((resolve, reject) => { |
| 167 | server.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve())); |
| 168 | }); |
| 169 | const addr = server.address(); |
| 170 | assert.ok(addr && typeof addr === 'object'); |
| 171 | base = `http://127.0.0.1:${addr.port}`; |
| 172 | }); |
| 173 | |
| 174 | after(async () => { |
| 175 | if (server) await new Promise((resolve) => server.close(() => resolve())); |
| 176 | globalThis.fetch = origFetch; |
| 177 | }); |
| 178 | |
| 179 | it('direct GET returns frontmatter object when upstream stores an object', async () => { |
| 180 | const res = await origFetch(`${base}/api/v1/notes/${encodeURIComponent('inbox/object-frontmatter.md')}`, { |
| 181 | headers: { Authorization: `Bearer ${bearer()}` }, |
| 182 | }); |
| 183 | const body = await res.json(); |
| 184 | |
| 185 | assert.equal(res.status, 200); |
| 186 | assert.deepEqual(body.frontmatter, { |
| 187 | title: 'Object Frontmatter', |
| 188 | project: 'object-project', |
| 189 | status: 'draft', |
| 190 | tags: ['object-tag'], |
| 191 | }); |
| 192 | }); |
| 193 | |
| 194 | it('direct GET parses hosted JSON-string frontmatter into an object', async () => { |
| 195 | const res = await origFetch(`${base}/api/v1/notes/${encodeURIComponent('inbox/string-frontmatter.md')}`, { |
| 196 | headers: { Authorization: `Bearer ${bearer()}` }, |
| 197 | }); |
| 198 | const body = await res.json(); |
| 199 | |
| 200 | assert.equal(res.status, 200); |
| 201 | assert.equal(typeof body.frontmatter, 'object'); |
| 202 | assert.equal(body.frontmatter.title, 'String Frontmatter'); |
| 203 | assert.equal(body.frontmatter.project, 'string-project'); |
| 204 | assert.deepEqual(body.frontmatter.tags, ['string-tag']); |
| 205 | }); |
| 206 | |
| 207 | it('direct GET exposes approved proposal tags and workflow fields', async () => { |
| 208 | const res = await origFetch(`${base}/api/v1/notes/${encodeURIComponent(PROPOSAL_PATH)}`, { |
| 209 | headers: { Authorization: `Bearer ${bearer()}` }, |
| 210 | }); |
| 211 | const body = await res.json(); |
| 212 | const serialized = JSON.stringify(body); |
| 213 | |
| 214 | assert.equal(res.status, 200); |
| 215 | assert.equal(body.path, PROPOSAL_PATH); |
| 216 | assert.equal(body.body.includes('Canonical approved script proposal.'), true); |
| 217 | assert.equal(body.frontmatter.title, PROPOSAL_FRONTMATTER.title); |
| 218 | assert.equal(body.frontmatter.project, 'born-free'); |
| 219 | assert.equal(body.frontmatter.status, 'approved'); |
| 220 | assert.equal(body.frontmatter.handoff_target, 'videofactory'); |
| 221 | assert.equal(body.frontmatter.handoff_status, 'ready'); |
| 222 | assert.deepEqual(body.frontmatter.tags, PROPOSAL_TAGS); |
| 223 | assert.equal(serialized.includes(GATEWAY_AUTH_SECRET), false); |
| 224 | }); |
| 225 | |
| 226 | it('list, facets, search, and direct GET agree on proposal note tags', async () => { |
| 227 | const auth = { Authorization: `Bearer ${bearer()}` }; |
| 228 | const directRes = await origFetch(`${base}/api/v1/notes/${encodeURIComponent(PROPOSAL_PATH)}`, { |
| 229 | headers: auth, |
| 230 | }); |
| 231 | const listRes = await origFetch(`${base}/api/v1/notes?limit=10&offset=0`, { headers: auth }); |
| 232 | const facetsRes = await origFetch(`${base}/api/v1/notes/facets`, { headers: auth }); |
| 233 | const searchRes = await origFetch(`${base}/api/v1/search`, { |
| 234 | method: 'POST', |
| 235 | headers: { ...auth, 'Content-Type': 'application/json' }, |
| 236 | body: JSON.stringify({ mode: 'keyword', query: 'ready-for-videofactory-handoff' }), |
| 237 | }); |
| 238 | |
| 239 | const direct = await directRes.json(); |
| 240 | const list = await listRes.json(); |
| 241 | const facets = await facetsRes.json(); |
| 242 | const search = await searchRes.json(); |
| 243 | const listed = list.notes.find((n) => n.path === PROPOSAL_PATH); |
| 244 | const searched = search.results.find((n) => n.path === PROPOSAL_PATH); |
| 245 | |
| 246 | assert.equal(directRes.status, 200); |
| 247 | assert.equal(listRes.status, 200); |
| 248 | assert.equal(facetsRes.status, 200); |
| 249 | assert.equal(searchRes.status, 200); |
| 250 | assert.ok(listed); |
| 251 | assert.ok(searched); |
| 252 | assert.deepEqual(direct.frontmatter.tags, PROPOSAL_TAGS); |
| 253 | assert.deepEqual(listed.frontmatter.tags, PROPOSAL_TAGS); |
| 254 | assert.deepEqual(searched.tags, PROPOSAL_TAGS); |
| 255 | assert.ok(facets.tags.includes('ready-for-videofactory-handoff')); |
| 256 | }); |
| 257 | }); |
File History
1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠
7 hours ago