verify-media-write-smoke.mjs
345 lines 11.4 KB
Raw
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