verify-hosted-task-write-smoke.mjs
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