verify-attachment-read-smoke.mjs
191 lines 6.2 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 #!/usr/bin/env node
2 /**
3 * Phase 2F-b-b — self-hosted Hub attachment read smoke (§10 checklist automation).
4 *
5 * Prerequisites:
6 * - Self-hosted Hub running with JWT auth (hub/server.mjs)
7 * - Vault with media file, mist frontmatter note, embedded URL note
8 *
9 * Usage:
10 * KNOWTATION_HUB_TOKEN='<jwt>' KNOWTATION_HUB_VAULT_ID=default \
11 * node scripts/verify-attachment-read-smoke.mjs
12 */
13
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { fileURLToPath } from 'node:url';
17 import { spawnSync } from 'node:child_process';
18 import dotenv from 'dotenv';
19
20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 const repoRoot = path.resolve(__dirname, '..');
22 dotenv.config({ path: path.join(repoRoot, '.env') });
23
24 function resolveToken() {
25 let t = process.env.KNOWTATION_HUB_TOKEN || process.env.HUB_JWT || '';
26 const fp = (process.env.KNOWTATION_HUB_TOKEN_FILE || '').trim();
27 if (!t && fp) {
28 const expanded = fp.startsWith('~') ? path.join(process.env.HOME || '', fp.slice(1)) : fp;
29 t = fs.readFileSync(expanded, 'utf8').trim();
30 }
31 return t;
32 }
33
34 const token = resolveToken();
35 const apiBase = (process.env.KNOWTATION_HUB_API || 'http://localhost:3000').replace(/\/$/, '');
36 const vaultId = process.env.KNOWTATION_HUB_VAULT_ID || 'default';
37
38 /** @type {{ step: string, ok: boolean, detail: string }[]} */
39 const results = [];
40
41 function record(step, ok, detail) {
42 results.push({ step, ok, detail });
43 const mark = ok ? 'PASS' : 'FAIL';
44 console.log(`[${mark}] ${step}: ${detail}`);
45 }
46
47 function headers(extra = {}) {
48 const h = {
49 Accept: 'application/json',
50 'Content-Type': 'application/json',
51 'X-Vault-Id': vaultId,
52 ...extra,
53 };
54 if (token) h.Authorization = 'Bearer ' + token;
55 return h;
56 }
57
58 /**
59 * @param {string} method
60 * @param {string} pathSuffix
61 * @param {object | undefined} body
62 */
63 async function api(method, pathSuffix, body) {
64 const res = await fetch(apiBase + pathSuffix, {
65 method,
66 headers: headers(),
67 body: body ? JSON.stringify(body) : undefined,
68 });
69 const text = await res.text();
70 let json = null;
71 try {
72 json = text ? JSON.parse(text) : null;
73 } catch {
74 json = { raw: text.slice(0, 200) };
75 }
76 return { status: res.status, json, text };
77 }
78
79 const LEAK_MARKERS = ['file://', '/Users/', 'token', 'X-Amz-', 'OCR'];
80
81 async function main() {
82 console.log(`2F-b-b attachment read smoke — ${apiBase} vault=${vaultId}`);
83
84 if (!token) {
85 record('auth', false, 'KNOWTATION_HUB_TOKEN (or _FILE) required');
86 process.exit(1);
87 }
88
89 const session = await api('GET', '/api/v1/auth/session');
90 if (session.status !== 200) {
91 record('auth', false, `session HTTP ${session.status} — refresh Hub JWT`);
92 process.exit(1);
93 }
94 record('auth', true, `session OK role=${session.json?.role ?? 'unknown'}`);
95
96 const list = await api('GET', '/api/v1/attachments');
97 const listOk =
98 list.status === 200 &&
99 list.json?.schema === 'knowtation.attachment_list/v0' &&
100 Array.isArray(list.json?.attachments) &&
101 list.json.attachments.length >= 1;
102 record(
103 'GET /api/v1/attachments',
104 listOk,
105 listOk ? `${list.json.attachments.length} attachments` : `HTTP ${list.status}`,
106 );
107
108 const vaultFile = list.json?.attachments?.find((a) => a.source === 'vault_file');
109 const fileGet = vaultFile
110 ? await api('GET', `/api/v1/attachments/${encodeURIComponent(vaultFile.attachment_id)}`)
111 : { status: 0, json: null };
112 const fileGetOk =
113 fileGet.status === 200 &&
114 fileGet.json?.attachment?.storage_kind === 'vault_blob' &&
115 Array.isArray(fileGet.json?.attachment?.linked_note_refs);
116 record(
117 'GET vault_file attachment',
118 fileGetOk,
119 fileGetOk ? vaultFile.attachment_id : `HTTP ${fileGet.status}`,
120 );
121
122 const embeddedList = await api('GET', '/api/v1/attachments?source=embedded_url');
123 const embeddedOk =
124 embeddedList.status === 200 &&
125 embeddedList.json?.attachments?.length >= 1 &&
126 embeddedList.json.attachments.every((a) => a.storage_kind === 'external_link') &&
127 embeddedList.json.attachments.every((a) => !String(a.display_label).includes('://'));
128 record(
129 'GET ?source=embedded_url',
130 embeddedOk,
131 embeddedOk ? 'host-only labels' : `HTTP ${embeddedList.status}`,
132 );
133
134 const projectRow = list.json?.attachments?.find((a) => a.scope === 'project');
135 if (projectRow && session.json?.role !== 'admin') {
136 const personalDenied = await api(
137 'GET',
138 `/api/v1/attachments/${encodeURIComponent(projectRow.attachment_id)}`,
139 undefined,
140 );
141 const deniedOk =
142 personalDenied.status === 404 && personalDenied.json?.code === 'unknown_attachment';
143 record('GET project attachment under personal role', deniedOk, `HTTP ${personalDenied.status}`);
144 } else {
145 record(
146 'GET project attachment under personal role',
147 true,
148 projectRow ? 'skipped — admin role or no project row' : 'no project-scoped row in vault',
149 );
150 }
151
152 const cli = spawnSync(
153 process.execPath,
154 [path.join(repoRoot, 'cli/index.mjs'), 'attachment', 'list', '--json', '--vault', vaultId],
155 { encoding: 'utf8', env: { ...process.env } },
156 );
157 let cliParity = false;
158 if (cli.status === 0 && listOk) {
159 try {
160 const cliJson = JSON.parse(cli.stdout.trim());
161 cliParity = JSON.stringify(cliJson) === JSON.stringify(list.json);
162 } catch {
163 cliParity = false;
164 }
165 }
166 record(
167 'CLI parity attachment list --json',
168 cliParity,
169 cliParity ? 'deep-equal to Hub list' : `exit=${cli.status}`,
170 );
171
172 const postWrite = await api('POST', '/api/v1/attachments', { display_label: 'nope' });
173 const noWriteOk = postWrite.status === 404 || postWrite.status === 405;
174 record('no POST /api/v1/attachments', noWriteOk, `HTTP ${postWrite.status}`);
175
176 const bodies = [list.text, fileGet.text ?? '', embeddedList.text].join('\n');
177 const leakHits = LEAK_MARKERS.filter((m) => bodies.includes(m));
178 record('grep leak markers', leakHits.length === 0, leakHits.length ? leakHits.join(', ') : 'zero matches');
179
180 const failed = results.filter((r) => !r.ok);
181 console.log(`\nSummary: ${results.length - failed.length}/${results.length} PASS`);
182 if (failed.length) {
183 process.exit(1);
184 }
185 console.log('2F-b-b attachment read smoke PASS');
186 }
187
188 main().catch((err) => {
189 console.error(err);
190 process.exit(1);
191 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago