verify-media-write-smoke.mjs
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0
feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes…
Human
minor
⚠ breaking
11 days ago
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Phase 2F-b-d-kn-b/c/d — self-hosted Hub media write smoke (propose only; approve via UI/test admin). |
| 4 | * |
| 5 | * Default (gates off): verifies propose routes refuse with MEDIA_*_DISABLED. |
| 6 | * |
| 7 | * Live mode (--live-propose): when dev/staging policy file (or env) has gates on and connector |
| 8 | * allowlist seeded: |
| 9 | * 1. auth session |
| 10 | * 2. POST import-consent (grant gdrive) |
| 11 | * 3. POST link-proposals → 201 knowtation.media_proposal/v0 |
| 12 | * 4. POST attach-proposals → 201 when attach gate on (2F-b-d-kn-d); 403 when off (2F-b-d-kn-c) |
| 13 | * |
| 14 | * Usage: |
| 15 | * KNOWTATION_HUB_TOKEN='<jwt>' KNOWTATION_HUB_VAULT_ID=default \ |
| 16 | * node scripts/verify-media-write-smoke.mjs |
| 17 | * |
| 18 | * Live propose (after scripts/seed-media-write-staging.mjs + Hub restart): |
| 19 | * KNOWTATION_HUB_API=http://localhost:3333 \ |
| 20 | * node scripts/verify-media-write-smoke.mjs --live-propose |
| 21 | */ |
| 22 | |
| 23 | import fs from 'node:fs'; |
| 24 | import path from 'node:path'; |
| 25 | import { fileURLToPath } from 'node:url'; |
| 26 | import dotenv from 'dotenv'; |
| 27 | |
| 28 | import { loadConfig } from '../lib/config.mjs'; |
| 29 | import { |
| 30 | getMediaAttachEnabled, |
| 31 | getMediaExternalLinkEnabled, |
| 32 | } from '../lib/attachments/attachment-write.mjs'; |
| 33 | import { noteStateIdFromParts } from '../lib/note-state-id.mjs'; |
| 34 | |
| 35 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 36 | const repoRoot = path.resolve(__dirname, '..'); |
| 37 | dotenv.config({ path: path.join(repoRoot, '.env') }); |
| 38 | |
| 39 | const livePropose = process.argv.includes('--live-propose'); |
| 40 | |
| 41 | function resolveDataDir() { |
| 42 | const fromEnv = (process.env.KNOWTATION_DATA_DIR || '').trim(); |
| 43 | if (fromEnv) return path.resolve(fromEnv); |
| 44 | const config = loadConfig(repoRoot); |
| 45 | const dir = config?.data_dir || 'data/'; |
| 46 | return path.isAbsolute(dir) ? dir : path.join(repoRoot, dir); |
| 47 | } |
| 48 | |
| 49 | function resolveToken() { |
| 50 | let t = |
| 51 | process.env.KNOWTATION_HUB_TOKEN || |
| 52 | process.env.HUB_JWT || |
| 53 | process.env.KNOWTATION_HUB_TOKEN_LOCAL || |
| 54 | ''; |
| 55 | const fp = (process.env.KNOWTATION_HUB_TOKEN_FILE || '').trim(); |
| 56 | if (!t && fp) { |
| 57 | const expanded = fp.startsWith('~') ? path.join(process.env.HOME || '', fp.slice(1)) : fp; |
| 58 | t = fs.readFileSync(expanded, 'utf8').trim(); |
| 59 | } |
| 60 | return t; |
| 61 | } |
| 62 | |
| 63 | const token = resolveToken(); |
| 64 | const apiBase = ( |
| 65 | process.env.KNOWTATION_HUB_API || |
| 66 | process.env.KNOWTATION_HUB_URL || |
| 67 | process.env.KNOWTATION_HUB_URL_LOCAL || |
| 68 | 'http://localhost:3000' |
| 69 | ).replace(/\/$/, ''); |
| 70 | const vaultId = |
| 71 | process.env.KNOWTATION_HUB_VAULT_ID || |
| 72 | process.env.KNOWTATION_HUB_VAULT_ID_LOCAL || |
| 73 | 'default'; |
| 74 | const dataDir = resolveDataDir(); |
| 75 | |
| 76 | /** @type {{ step: string, ok: boolean, detail: string }[]} */ |
| 77 | const results = []; |
| 78 | |
| 79 | function record(step, ok, detail) { |
| 80 | results.push({ step, ok, detail }); |
| 81 | console.log(`[${ok ? 'PASS' : 'FAIL'}] ${step}: ${detail}`); |
| 82 | } |
| 83 | |
| 84 | function headers(extra = {}) { |
| 85 | const h = { |
| 86 | Accept: 'application/json', |
| 87 | 'Content-Type': 'application/json', |
| 88 | 'X-Vault-Id': vaultId, |
| 89 | ...extra, |
| 90 | }; |
| 91 | if (token) h.Authorization = 'Bearer ' + token; |
| 92 | return h; |
| 93 | } |
| 94 | |
| 95 | async function api(method, pathSuffix, body) { |
| 96 | const res = await fetch(apiBase + pathSuffix, { |
| 97 | method, |
| 98 | headers: headers(), |
| 99 | body: body ? JSON.stringify(body) : undefined, |
| 100 | }); |
| 101 | const text = await res.text(); |
| 102 | let json = null; |
| 103 | try { |
| 104 | json = text ? JSON.parse(text) : null; |
| 105 | } catch { |
| 106 | json = { raw: text.slice(0, 200) }; |
| 107 | } |
| 108 | return { status: res.status, json, text }; |
| 109 | } |
| 110 | |
| 111 | async function runGatedOffSmoke() { |
| 112 | const linkOff = await api('POST', '/api/v1/attachments/link-proposals', { |
| 113 | intent: 'smoke', |
| 114 | scope: 'personal', |
| 115 | connector_id: 'gdrive', |
| 116 | opaque_ref: 'smoke-ref', |
| 117 | consent_id: 'mic_0123456789abcdef', |
| 118 | }); |
| 119 | const linkGateOk = |
| 120 | linkOff.status === 403 && linkOff.json?.code === 'MEDIA_EXTERNAL_LINK_DISABLED'; |
| 121 | record('link gate off → MEDIA_EXTERNAL_LINK_DISABLED', linkGateOk, `HTTP ${linkOff.status}`); |
| 122 | |
| 123 | const attachOff = await api('POST', '/api/v1/attachments/attach-proposals', { |
| 124 | intent: 'smoke', |
| 125 | scope: 'personal', |
| 126 | attachment_id: 'att_file_' + '0'.repeat(32), |
| 127 | note_ref: 'note:notes/smoke.md', |
| 128 | base_state_id: 'kn1_' + '0'.repeat(16), |
| 129 | }); |
| 130 | const attachGateOk = |
| 131 | attachOff.status === 403 && attachOff.json?.code === 'MEDIA_ATTACH_DISABLED'; |
| 132 | record('attach gate off → MEDIA_ATTACH_DISABLED', attachGateOk, `HTTP ${attachOff.status}`); |
| 133 | |
| 134 | const consentList = await api('GET', '/api/v1/attachments/import-consents'); |
| 135 | const listOk = |
| 136 | consentList.status === 200 && |
| 137 | consentList.json?.schema === 'knowtation.media_import_consent_list/v0'; |
| 138 | record('GET import-consents (read always allowed)', listOk, `HTTP ${consentList.status}`); |
| 139 | |
| 140 | record('--live-propose skipped', true, 'default — gates off; pass disabled checks only'); |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * Discover an attachment_id and note target for live attach propose. |
| 145 | * @returns {Promise<{ attachmentId: string, noteRef: string, baseStateId: string }|null>} |
| 146 | */ |
| 147 | async function discoverAttachTargets() { |
| 148 | const configuredPath = (process.env.MEDIA_SMOKE_NOTE_PATH || '').trim(); |
| 149 | /** @type {string[]} */ |
| 150 | const notePathCandidates = []; |
| 151 | if (configuredPath) { |
| 152 | notePathCandidates.push(configuredPath.replace(/^note:/, '')); |
| 153 | } |
| 154 | notePathCandidates.push('inbox/note-VIDEO-URL_TEST.md', 'inbox/test-proposal.md'); |
| 155 | |
| 156 | const notesList = await api('GET', '/api/v1/notes?limit=5'); |
| 157 | if (notesList.status === 200 && Array.isArray(notesList.json?.notes)) { |
| 158 | for (const n of notesList.json.notes) { |
| 159 | if (typeof n?.path === 'string' && !notePathCandidates.includes(n.path)) { |
| 160 | notePathCandidates.push(n.path); |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | let noteRef = null; |
| 166 | let baseStateId = null; |
| 167 | for (const notePath of notePathCandidates) { |
| 168 | const noteGet = await api('GET', `/api/v1/notes/${encodeURIComponent(notePath)}`); |
| 169 | if (noteGet.status !== 200) continue; |
| 170 | noteRef = notePath.startsWith('note:') ? notePath : `note:${notePath}`; |
| 171 | baseStateId = noteStateIdFromParts(noteGet.json?.frontmatter ?? {}, noteGet.json?.body ?? ''); |
| 172 | break; |
| 173 | } |
| 174 | if (!noteRef || !baseStateId) return null; |
| 175 | |
| 176 | const attachList = await api('GET', '/api/v1/attachments?limit=20'); |
| 177 | const attachmentId = attachList.json?.attachments?.[0]?.attachment_id; |
| 178 | if (!attachmentId) return null; |
| 179 | |
| 180 | return { attachmentId, noteRef, baseStateId }; |
| 181 | } |
| 182 | |
| 183 | async function runLiveProposeSmoke() { |
| 184 | const attachGateOn = getMediaAttachEnabled(dataDir); |
| 185 | const linkGateOn = getMediaExternalLinkEnabled(dataDir); |
| 186 | record( |
| 187 | 'policy gates (data_dir)', |
| 188 | linkGateOn, |
| 189 | `link=${linkGateOn ? 'ON' : 'OFF'} attach=${attachGateOn ? 'ON' : 'OFF'} dir=${dataDir}`, |
| 190 | ); |
| 191 | |
| 192 | const smokeSuffix = String(Date.now()); |
| 193 | const opaqueRef = `smoke-${smokeSuffix}`; |
| 194 | |
| 195 | const consentGrant = await api('POST', '/api/v1/attachments/import-consents', { |
| 196 | connector_id: 'gdrive', |
| 197 | scope: 'personal', |
| 198 | expires_at: null, |
| 199 | }); |
| 200 | const consentOk = |
| 201 | consentGrant.status === 201 && |
| 202 | consentGrant.json?.schema === 'knowtation.media_import_consent/v0' && |
| 203 | typeof consentGrant.json?.consent_id === 'string'; |
| 204 | record( |
| 205 | 'grant import consent (gdrive)', |
| 206 | consentOk, |
| 207 | consentOk |
| 208 | ? `consent_id=${consentGrant.json.consent_id}` |
| 209 | : `HTTP ${consentGrant.status} code=${consentGrant.json?.code ?? ''}`, |
| 210 | ); |
| 211 | |
| 212 | const consentId = consentOk ? consentGrant.json.consent_id : 'mic_0123456789abcdef'; |
| 213 | |
| 214 | const linkLive = await api('POST', '/api/v1/attachments/link-proposals', { |
| 215 | intent: '2F-b-d-kn-d smoke live link propose', |
| 216 | scope: 'personal', |
| 217 | connector_id: 'gdrive', |
| 218 | opaque_ref: opaqueRef, |
| 219 | consent_id: consentId, |
| 220 | display_label: 'Smoke board', |
| 221 | }); |
| 222 | const linkLiveOk = |
| 223 | linkLive.status === 201 && linkLive.json?.schema === 'knowtation.media_proposal/v0'; |
| 224 | record( |
| 225 | 'live link propose', |
| 226 | linkLiveOk, |
| 227 | linkLiveOk |
| 228 | ? `proposal_id=${linkLive.json.proposal_id} kind=${linkLive.json.proposal_kind}` |
| 229 | : `HTTP ${linkLive.status} code=${linkLive.json?.code ?? ''}`, |
| 230 | ); |
| 231 | |
| 232 | if (attachGateOn) { |
| 233 | const targets = await discoverAttachTargets(); |
| 234 | if (!targets) { |
| 235 | record( |
| 236 | 'discover attach targets', |
| 237 | false, |
| 238 | 'need ≥1 attachment in vault and readable note (set MEDIA_SMOKE_NOTE_PATH)', |
| 239 | ); |
| 240 | } else { |
| 241 | record( |
| 242 | 'discover attach targets', |
| 243 | true, |
| 244 | `attachment_id=${targets.attachmentId} note_ref=${targets.noteRef}`, |
| 245 | ); |
| 246 | |
| 247 | const attachLive = await api('POST', '/api/v1/attachments/attach-proposals', { |
| 248 | intent: '2F-b-d-kn-d smoke live attach propose', |
| 249 | scope: 'personal', |
| 250 | attachment_id: targets.attachmentId, |
| 251 | note_ref: targets.noteRef, |
| 252 | base_state_id: targets.baseStateId, |
| 253 | }); |
| 254 | const attachLiveOk = |
| 255 | attachLive.status === 201 && attachLive.json?.schema === 'knowtation.media_proposal/v0'; |
| 256 | record( |
| 257 | 'live attach propose', |
| 258 | attachLiveOk, |
| 259 | attachLiveOk |
| 260 | ? `proposal_id=${attachLive.json.proposal_id} kind=${attachLive.json.proposal_kind}` |
| 261 | : `HTTP ${attachLive.status} code=${attachLive.json?.code ?? ''}`, |
| 262 | ); |
| 263 | } |
| 264 | } else { |
| 265 | const attachLive = await api('POST', '/api/v1/attachments/attach-proposals', { |
| 266 | intent: 'smoke attach must stay disabled', |
| 267 | scope: 'personal', |
| 268 | attachment_id: linkLive.json?.attachment_id ?? 'att_link_' + '0'.repeat(32), |
| 269 | note_ref: 'note:notes/smoke.md', |
| 270 | base_state_id: 'kn1_' + '0'.repeat(16), |
| 271 | }); |
| 272 | const attachStillOff = |
| 273 | attachLive.status === 403 && attachLive.json?.code === 'MEDIA_ATTACH_DISABLED'; |
| 274 | record( |
| 275 | 'attach gate still off → MEDIA_ATTACH_DISABLED', |
| 276 | attachStillOff, |
| 277 | attachStillOff |
| 278 | ? '403 MEDIA_ATTACH_DISABLED' |
| 279 | : `HTTP ${attachLive.status} code=${attachLive.json?.code ?? ''}`, |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | const consentList = await api('GET', '/api/v1/attachments/import-consents'); |
| 284 | const listOk = |
| 285 | consentList.status === 200 && |
| 286 | consentList.json?.schema === 'knowtation.media_import_consent_list/v0'; |
| 287 | record('GET import-consents (read always allowed)', listOk, `HTTP ${consentList.status}`); |
| 288 | } |
| 289 | |
| 290 | async function verifyAuth() { |
| 291 | const session = await api('GET', '/api/v1/auth/session'); |
| 292 | if (session.status === 200) { |
| 293 | record('auth', true, `session OK role=${session.json?.role ?? 'unknown'}`); |
| 294 | return true; |
| 295 | } |
| 296 | // Self-hosted hub (hub/server.mjs) has no /auth/session — probe import-consents read instead. |
| 297 | const probe = await api('GET', '/api/v1/attachments/import-consents'); |
| 298 | if (probe.status === 200) { |
| 299 | record('auth', true, 'self-hosted probe OK (import-consents HTTP 200)'); |
| 300 | return true; |
| 301 | } |
| 302 | record( |
| 303 | 'auth', |
| 304 | false, |
| 305 | `session HTTP ${session.status}; import-consents probe HTTP ${probe.status} — refresh Hub JWT`, |
| 306 | ); |
| 307 | return false; |
| 308 | } |
| 309 | |
| 310 | async function main() { |
| 311 | console.log( |
| 312 | `2F-b-d-kn media write smoke — ${apiBase} vault=${vaultId} livePropose=${livePropose}`, |
| 313 | ); |
| 314 | |
| 315 | if (!token) { |
| 316 | record('auth', false, 'KNOWTATION_HUB_TOKEN (or _FILE) required'); |
| 317 | process.exit(1); |
| 318 | } |
| 319 | |
| 320 | if (!(await verifyAuth())) process.exit(1); |
| 321 | |
| 322 | if (livePropose) { |
| 323 | await runLiveProposeSmoke(); |
| 324 | } else { |
| 325 | await runGatedOffSmoke(); |
| 326 | } |
| 327 | |
| 328 | const failed = results.filter((r) => !r.ok); |
| 329 | console.log(`\nSummary: ${results.length - failed.length}/${results.length} PASS`); |
| 330 | if (failed.length) process.exit(1); |
| 331 | |
| 332 | const attachOn = getMediaAttachEnabled(dataDir); |
| 333 | console.log( |
| 334 | livePropose |
| 335 | ? attachOn |
| 336 | ? '2F-b-d-kn-d media write smoke PASS (live link + attach propose)' |
| 337 | : '2F-b-d-kn-c media write smoke PASS (live link propose; attach gate confirmed off)' |
| 338 | : '2F-b-d-kn-b media write smoke PASS (propose-only; gates confirmed off)', |
| 339 | ); |
| 340 | } |
| 341 | |
| 342 | main().catch((err) => { |
| 343 | console.error(err); |
| 344 | process.exit(1); |
| 345 | }); |
File History
1 commit
sha256:93bcf8f9bd56d8c5b9339f4ec73b9ebd66571398d56262d38eedc2cfa9db9882
fix(test): align Band B landing assertion with desktop MCP …
Human
11 days ago