verify-hosted-task-write-smoke.mjs
232 lines 7.1 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago
1 #!/usr/bin/env node
2 /**
3 * Phase 2G-d — hosted Hub task write smoke (propose path).
4 *
5 * Default (gates off): verifies propose routes refuse with TASK_WRITES_DISABLED.
6 *
7 * Live mode (--live-propose): 4/4 PASS on prod when bridge has TASK_WRITES_ENABLED=1:
8 * 1. auth session
9 * 2. POST /api/v1/tasks/proposals (task_create)
10 * 3. POST /api/v1/task-loops/proposals (task_loop_create)
11 * 4. POST /api/v1/task-loops/{loopId}/instances/proposals (materialize)
12 *
13 * Usage:
14 * KNOWTATION_HUB_TOKEN='<jwt>' KNOWTATION_HUB_VAULT_ID=Business \
15 * node scripts/verify-hosted-task-write-smoke.mjs
16 *
17 * Live propose on prod (bridge TASK_WRITES_ENABLED=1):
18 * KNOWTATION_HUB_API=https://api.knowtation.store \
19 * node scripts/verify-hosted-task-write-smoke.mjs --live-propose
20 */
21
22 import fs from 'node:fs';
23 import path from 'node:path';
24 import { fileURLToPath } from 'node:url';
25 import dotenv from 'dotenv';
26
27 const __dirname = path.dirname(fileURLToPath(import.meta.url));
28 const repoRoot = path.resolve(__dirname, '..');
29 dotenv.config({ path: path.join(repoRoot, '.env') });
30
31 const livePropose = process.argv.includes('--live-propose');
32 const MATERIALIZE_LOOP_ID = 'loop_school_trip';
33
34 function resolveToken() {
35 let t = process.env.KNOWTATION_HUB_TOKEN || process.env.HUB_JWT || '';
36 const fp = (process.env.KNOWTATION_HUB_TOKEN_FILE || '').trim();
37 if (!t && fp) {
38 const expanded = fp.startsWith('~') ? path.join(process.env.HOME || '', fp.slice(1)) : fp;
39 t = fs.readFileSync(expanded, 'utf8').trim();
40 }
41 return t;
42 }
43
44 const token = resolveToken();
45 const apiBase = (process.env.KNOWTATION_HUB_API || 'http://localhost:3000').replace(/\/$/, '');
46 const vaultId = process.env.KNOWTATION_HUB_VAULT_ID || 'default';
47
48 /** @type {{ step: string, ok: boolean, detail: string }[]} */
49 const results = [];
50
51 function record(step, ok, detail) {
52 results.push({ step, ok, detail });
53 console.log(`[${ok ? 'PASS' : 'FAIL'}] ${step}: ${detail}`);
54 }
55
56 function headers(extra = {}) {
57 const h = {
58 Accept: 'application/json',
59 'Content-Type': 'application/json',
60 'X-Vault-Id': vaultId,
61 ...extra,
62 };
63 if (token) h.Authorization = 'Bearer ' + token;
64 return h;
65 }
66
67 async function api(method, pathSuffix, body) {
68 const res = await fetch(apiBase + pathSuffix, {
69 method,
70 headers: headers(),
71 body: body ? JSON.stringify(body) : undefined,
72 });
73 const text = await res.text();
74 let json = null;
75 try {
76 json = text ? JSON.parse(text) : null;
77 } catch {
78 json = { raw: text.slice(0, 200) };
79 }
80 return { status: res.status, json };
81 }
82
83 async function runGatedOffSmoke() {
84 const disabled = await api('POST', '/api/v1/tasks/proposals', {
85 proposal_kind: 'task_create',
86 intent: 'smoke should refuse when gated off',
87 task: {
88 task_id: 'task_smoke_disabled',
89 kind: 'personal',
90 scope: 'personal',
91 title: 'Smoke disabled',
92 workspace_id: 'ws_personal',
93 due_at: null,
94 assignee_ref: null,
95 assigner_ref: null,
96 artifact_links: [],
97 },
98 });
99 const disabledOk = disabled.status === 403 && disabled.json?.code === 'TASK_WRITES_DISABLED';
100 record(
101 'POST /api/v1/tasks/proposals gated off',
102 disabledOk,
103 disabledOk ? '403 TASK_WRITES_DISABLED' : `HTTP ${disabled.status} code=${disabled.json?.code ?? 'none'}`,
104 );
105
106 const loopDisabled = await api('POST', '/api/v1/task-loops/proposals', {
107 proposal_kind: 'task_loop_create',
108 intent: 'smoke loop refuse',
109 loop: {
110 loop_id: 'loop_smoke_disabled',
111 kind: 'personal',
112 scope: 'personal',
113 title: 'Smoke loop',
114 workspace_id: 'ws_personal',
115 status: 'active',
116 recurrence: { kind: 'manual' },
117 timezone: 'UTC',
118 boundary_policy: 'observe_only',
119 memory_links: [],
120 },
121 });
122 const loopDisabledOk = loopDisabled.status === 403 && loopDisabled.json?.code === 'TASK_WRITES_DISABLED';
123 record(
124 'POST /api/v1/task-loops/proposals gated off',
125 loopDisabledOk,
126 loopDisabledOk ? '403 TASK_WRITES_DISABLED' : `HTTP ${loopDisabled.status}`,
127 );
128
129 record('--live-propose skipped', true, 'default — gates off; pass disabled checks only');
130 }
131
132 async function runLiveProposeSmoke() {
133 const smokeSuffix = String(Date.now());
134
135 const live = await api('POST', '/api/v1/tasks/proposals', {
136 proposal_kind: 'task_create',
137 intent: 'hosted smoke live task propose',
138 task: {
139 task_id: `task_smoke_${smokeSuffix}`,
140 kind: 'personal',
141 scope: 'personal',
142 title: 'Hosted smoke task',
143 workspace_id: 'ws_personal',
144 due_at: null,
145 assignee_ref: null,
146 assigner_ref: null,
147 artifact_links: [],
148 },
149 });
150 const liveOk = live.status === 201 && live.json?.schema === 'knowtation.task_proposal/v0';
151 record(
152 'live task propose',
153 liveOk,
154 liveOk ? `proposal_id=${live.json.proposal_id}` : `HTTP ${live.status} code=${live.json?.code ?? ''}`,
155 );
156
157 const loopLive = await api('POST', '/api/v1/task-loops/proposals', {
158 proposal_kind: 'task_loop_create',
159 intent: 'hosted smoke live loop propose',
160 loop: {
161 loop_id: `loop_smoke_${smokeSuffix}`,
162 kind: 'personal',
163 scope: 'personal',
164 title: 'Hosted smoke loop',
165 workspace_id: 'ws_personal',
166 status: 'active',
167 recurrence: { kind: 'manual' },
168 timezone: 'UTC',
169 boundary_policy: 'observe_only',
170 memory_links: [],
171 },
172 });
173 const loopLiveOk =
174 loopLive.status === 201 && loopLive.json?.schema === 'knowtation.task_proposal/v0';
175 record(
176 'live loop propose',
177 loopLiveOk,
178 loopLiveOk
179 ? `proposal_id=${loopLive.json.proposal_id}`
180 : `HTTP ${loopLive.status} code=${loopLive.json?.code ?? ''}`,
181 );
182
183 const instanceLive = await api(
184 'POST',
185 `/api/v1/task-loops/${encodeURIComponent(MATERIALIZE_LOOP_ID)}/instances/proposals`,
186 {
187 intent: 'hosted smoke live instance materialize',
188 occurrence_key: `smoke-${smokeSuffix}`,
189 },
190 );
191 const instanceLiveOk =
192 instanceLive.status === 201 && instanceLive.json?.schema === 'knowtation.task_instance_proposal/v0';
193 record(
194 'live instance materialize propose',
195 instanceLiveOk,
196 instanceLiveOk
197 ? `proposal_id=${instanceLive.json.proposal_id} occurrence=${instanceLive.json.occurrence_key ?? ''}`
198 : `HTTP ${instanceLive.status} code=${instanceLive.json?.code ?? ''}`,
199 );
200 }
201
202 async function main() {
203 console.log(`2G-d task write smoke — ${apiBase} vault=${vaultId} livePropose=${livePropose}`);
204
205 if (!token) {
206 record('auth', false, 'KNOWTATION_HUB_TOKEN (or _FILE) required');
207 process.exit(1);
208 }
209
210 const session = await api('GET', '/api/v1/auth/session');
211 if (session.status !== 200) {
212 record('auth', false, `session HTTP ${session.status} — refresh Hub JWT`);
213 process.exit(1);
214 }
215 record('auth', true, `session OK role=${session.json?.role ?? 'unknown'}`);
216
217 if (livePropose) {
218 await runLiveProposeSmoke();
219 } else {
220 await runGatedOffSmoke();
221 }
222
223 const failed = results.filter((r) => !r.ok);
224 console.log(`\nSummary: ${results.length - failed.length}/${results.length} PASS`);
225 if (failed.length) process.exit(1);
226 console.log('2G-d task write smoke PASS');
227 }
228
229 main().catch((err) => {
230 console.error(err);
231 process.exit(1);
232 });
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago