@lifeaitools/clauth 1.31.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,447 @@ import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import vm from 'node:vm';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
8
+ import { dashboardHtml } from './commands/serve.js';
9
+
7
10
  const here = path.dirname(fileURLToPath(import.meta.url));
8
11
  const serveSource = fs.readFileSync(path.join(here, 'commands', 'serve.js'), 'utf8');
9
12
 
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Functional harness
15
+ //
16
+ // The dashboard JS ships inside a served HTML template, so there is nothing to
17
+ // import. This harness renders the real page with dashboardHtml(), lifts its
18
+ // script into a vm realm with a stub DOM + stub fetch, and then calls the real
19
+ // controller seams (runSupervisorSurface, addService, submitWriteUnlock, …)
20
+ // with simulated inputs — per the global UI functional-harness rule. No pixels,
21
+ // no browser automation: assertions are on state, command and payload.
22
+ //
23
+ // The requirement these tests encode: a dashboard button IS the human being
24
+ // present. On an unlocked vault a click must proceed with no password prompt.
25
+ // The prompt exists only for a genuinely locked vault.
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ function makeElement(id) {
29
+ const classes = new Set();
30
+ return {
31
+ id,
32
+ value: '',
33
+ textContent: '',
34
+ innerHTML: '',
35
+ className: '',
36
+ title: '',
37
+ disabled: false,
38
+ dataset: {},
39
+ style: {},
40
+ scrollHeight: 0,
41
+ scrollTop: 0,
42
+ clientHeight: 0,
43
+ classList: {
44
+ add: (c) => classes.add(c),
45
+ remove: (c) => classes.delete(c),
46
+ toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
47
+ contains: (c) => classes.has(c),
48
+ },
49
+ addEventListener() {},
50
+ removeEventListener() {},
51
+ appendChild() {},
52
+ focus() {},
53
+ click() {},
54
+ querySelectorAll: () => [],
55
+ };
56
+ }
57
+
58
+ function bootDashboard({ initWriteToken = null, route } = {}) {
59
+ const html = dashboardHtml(53999, [], false, initWriteToken);
60
+ const start = html.indexOf('<script>');
61
+ const end = html.lastIndexOf('</script>');
62
+ assert.ok(start !== -1 && end > start, 'dashboard HTML must contain exactly one script block');
63
+ const script = html.slice(start + '<script>'.length, end);
64
+
65
+ const elements = new Map();
66
+ const fetchCalls = [];
67
+ const confirmCalls = [];
68
+
69
+ const sandbox = {
70
+ console: { log() {}, warn() {}, error() {} },
71
+ JSON,
72
+ Date,
73
+ Math,
74
+ setTimeout: () => 0,
75
+ clearTimeout: () => {},
76
+ setInterval: () => 0,
77
+ clearInterval: () => {},
78
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
79
+ navigator: { clipboard: { writeText: async () => {} } },
80
+ confirm: (msg) => { confirmCalls.push(msg); return true; },
81
+ alert: () => {},
82
+ document: {
83
+ getElementById(id) {
84
+ if (!elements.has(id)) elements.set(id, makeElement(id));
85
+ return elements.get(id);
86
+ },
87
+ createElement: (tag) => makeElement(tag),
88
+ querySelectorAll: () => [],
89
+ addEventListener() {},
90
+ body: makeElement('body'),
91
+ },
92
+ async fetch(url, options) {
93
+ const opts = options || {};
94
+ fetchCalls.push({ url: String(url), options: opts });
95
+ const body = route ? route(String(url), opts) : null;
96
+ // Default: vault reports itself locked, so boot() lands on the lock
97
+ // screen and issues no follow-up requests. Keeps the realm deterministic.
98
+ const json = body !== null && body !== undefined ? body : { locked: true, hard_locked: false };
99
+ return {
100
+ ok: true,
101
+ status: 200,
102
+ json: async () => json,
103
+ text: async () => JSON.stringify(json),
104
+ };
105
+ },
106
+ };
107
+ sandbox.location = { href: '', reload() {} };
108
+ sandbox.open = () => {};
109
+
110
+ vm.createContext(sandbox);
111
+ sandbox.window = sandbox;
112
+ sandbox.globalThis = sandbox;
113
+ vm.runInContext(script, sandbox, { filename: 'dashboard.js' });
114
+
115
+ return {
116
+ ctx: sandbox,
117
+ elements,
118
+ fetchCalls,
119
+ confirmCalls,
120
+ el: (id) => sandbox.document.getElementById(id),
121
+ /** Requests that actually carried write authority — i.e. real writes. */
122
+ writeCalls: () =>
123
+ fetchCalls.filter((c) => c.options.headers && c.options.headers['X-Clauth-Write-Token']),
124
+ tokenAcquisitions: () => fetchCalls.filter((c) => c.url.endsWith('/write-token')),
125
+ authCalls: () => fetchCalls.filter((c) => c.url.endsWith('/auth')),
126
+ settle: () => new Promise((r) => setImmediate(r)),
127
+ };
128
+ }
129
+
130
+ const SURFACE_ID = 'factory-test-plugin:primary';
131
+ const ACTIONS_PATH = '/v1/surfaces/' + encodeURIComponent(SURFACE_ID) + '/actions';
132
+
133
+ /** Vault unlocked: /write-token always hands back a current token. */
134
+ function unlockedRoute(url, options) {
135
+ if (url.endsWith('/write-token')) return { ok: true, write_token: 'wt-fresh' };
136
+ if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
137
+ return { operationId: 'op-42', resulting_state: { state: 'started', ok: true } };
138
+ }
139
+ if (url.endsWith('/add-service')) return { ok: true };
140
+ if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
141
+ return {};
142
+ }
143
+
144
+ /** Vault genuinely locked: /write-token refuses, /auth can still unlock it. */
145
+ function lockedRoute(url, options) {
146
+ if (url.endsWith('/write-token')) return { error: 'vault_locked', locked: true };
147
+ if (url.endsWith('/auth')) return { write_token: 'wt-after-password' };
148
+ if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
149
+ return { operationId: 'op-43', resulting_state: { state: 'started', ok: true } };
150
+ }
151
+ if (url.endsWith('/add-service')) return { ok: true };
152
+ if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
153
+ return {};
154
+ }
155
+
156
+ // ── 1. The requirement: a button on an unlocked vault just works ─────────────
157
+
158
+ test('unlocked vault, page holds no token: the action proceeds with NO password prompt', async () => {
159
+ // This is the --pw / boot.key auto-unlock case — the page never saw the lock
160
+ // screen, so dashboardHtml() injected no token. Dave's reported flow.
161
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
162
+ await h.settle();
163
+
164
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
165
+ await h.settle();
166
+
167
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no password modal may open');
168
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'nothing may be parked');
169
+ assert.deepEqual(h.authCalls(), [], 'no password round-trip may occur');
170
+
171
+ const writes = h.writeCalls();
172
+ assert.equal(writes.length, 1, 'the action must fire, once');
173
+ assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
174
+ assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
175
+ assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' });
176
+ });
177
+
178
+ test('unlocked vault, page holds a STALE token: the write uses a freshly acquired one', async () => {
179
+ // The literal defect: the server write session has a 10-minute TTL while a
180
+ // dashboard tab stays open for hours, so the page kept sending a token the
181
+ // daemon had already expired and writeGuard answered 403 "write token
182
+ // required". Acquiring per action makes that unreachable.
183
+ const h = bootDashboard({ initWriteToken: 'wt-expired-hours-ago', route: unlockedRoute });
184
+ await h.settle();
185
+
186
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
187
+ await h.settle();
188
+
189
+ const writes = h.writeCalls();
190
+ assert.equal(writes.length, 1);
191
+ assert.equal(
192
+ writes[0].options.headers['X-Clauth-Write-Token'],
193
+ 'wt-fresh',
194
+ 'must send the refreshed token, never the stale page-load one',
195
+ );
196
+ assert.equal(h.tokenAcquisitions().length, 1, 'exactly one token acquisition per action');
197
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex');
198
+ });
199
+
200
+ test('Add Service on an unlocked vault creates the service with no prompt', async () => {
201
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
202
+ await h.settle();
203
+
204
+ h.el('add-name').value = 'my-new-service';
205
+ h.el('add-label').value = 'My New Service';
206
+ h.el('add-description').value = '';
207
+ h.el('add-project').value = '';
208
+ h.el('add-type').value = 'token';
209
+
210
+ await h.ctx.addService();
211
+ await h.settle();
212
+
213
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no modal for Add Service');
214
+ assert.deepEqual(h.authCalls(), [], 'no password round-trip');
215
+
216
+ const writes = h.writeCalls();
217
+ assert.equal(writes.length, 1, 'Add Service must fire exactly once');
218
+ assert.ok(writes[0].url.endsWith('/add-service'), 'got ' + writes[0].url);
219
+ assert.equal(writes[0].options.method, 'POST');
220
+ assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
221
+ assert.deepEqual(JSON.parse(writes[0].options.body), {
222
+ name: 'my-new-service',
223
+ key_type: 'token',
224
+ label: 'My New Service',
225
+ });
226
+ assert.match(h.el('add-msg').textContent, /my-new-service created/);
227
+ });
228
+
229
+ test('every write action reaches the acquire path, not just the two under test', async () => {
230
+ // Spot-check the breadth of the choke point: each of these is a distinct
231
+ // call site that previously threw its own opaque string.
232
+ for (const [name, args] of [
233
+ ['rescanSupervisorPlugins', []],
234
+ ['deleteService', ['some-service']],
235
+ ['toggleService', ['some-service']],
236
+ ['submitMount', []],
237
+ ['deleteMount', ['some-mount']],
238
+ ]) {
239
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
240
+ await h.settle();
241
+ // submitMount reads two inputs and returns early when either is blank.
242
+ h.el('mount-name').value = 'm';
243
+ h.el('mount-path').value = 'C:/tmp';
244
+ h.el('badge-some-service').classList.add('on');
245
+
246
+ await h.ctx[name](...args);
247
+ await h.settle();
248
+
249
+ assert.equal(h.tokenAcquisitions().length, 1, name + ' must acquire write access');
250
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', name + ' must not prompt');
251
+ assert.equal(h.ctx.hasPendingWriteAction(), false, name + ' must not park');
252
+ }
253
+ });
254
+
255
+ // ── 2. Locked-vault fallback — the only case that may prompt ─────────────────
256
+
257
+ test('genuinely locked vault: the action parks, prompts, and performs no write', async () => {
258
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
259
+ await h.settle();
260
+
261
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
262
+ await h.settle();
263
+
264
+ assert.equal(h.el('write-unlock-overlay').style.display, 'flex', 'locked vault must prompt');
265
+ assert.equal(h.ctx.hasPendingWriteAction(), true);
266
+ assert.equal(h.ctx.pendingWriteActionName(), 'runSupervisorSurface');
267
+ assert.deepEqual(h.writeCalls(), [], 'no write may be attempted against a locked vault');
268
+ });
269
+
270
+ test('locked vault: after the password unlock the parked action fires exactly once', async () => {
271
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
272
+ await h.settle();
273
+
274
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
275
+ await h.settle();
276
+ assert.deepEqual(h.writeCalls(), []);
277
+
278
+ h.el('write-unlock-input').value = 'correct-horse';
279
+ await h.ctx.submitWriteUnlock();
280
+ await h.settle();
281
+
282
+ const writes = h.writeCalls();
283
+ assert.equal(writes.length, 1, 'exactly once — not zero, not twice');
284
+ assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
285
+ assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' }, 'arguments preserved');
286
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'park consumed');
287
+ assert.equal(h.el('write-unlock-overlay').style.display, 'none', 'modal closed');
288
+ });
289
+
290
+ test('locked vault: cancelling clears the parked action, and no write fires later', async () => {
291
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
292
+ await h.settle();
293
+
294
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
295
+ await h.settle();
296
+ assert.equal(h.ctx.hasPendingWriteAction(), true);
297
+
298
+ h.ctx.closeWriteUnlockModal();
299
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'cancel must disarm the parked write');
300
+ assert.deepEqual(h.writeCalls(), []);
301
+
302
+ h.ctx.unlockWrites();
303
+ h.el('write-unlock-input').value = 'correct-horse';
304
+ await h.ctx.submitWriteUnlock();
305
+ await h.settle();
306
+
307
+ assert.deepEqual(h.writeCalls(), [], 'a cancelled action must never fire, then or later');
308
+ });
309
+
310
+ // ── 3. Registry completeness — the guard against a per-call-site regression ──
311
+
312
+ /**
313
+ * The reviewed write-action surface. Hand-maintained on purpose: growing it
314
+ * must be a decision someone made, not a number that drifts upward. A floor
315
+ * like `callers.size >= 15` cannot do this job — on a passing run the two
316
+ * set-differences below already force callers.size === registered.size, so a
317
+ * floor only ever catches the registry SHRINKING.
318
+ */
319
+ const EXPECTED_WRITE_ACTIONS = [
320
+ 'addService',
321
+ 'changePassword',
322
+ 'deleteMount',
323
+ 'deleteService',
324
+ 'enrollMachine',
325
+ 'rescanSupervisorPlugins',
326
+ 'rotateKey',
327
+ 'runSupervisorSurface',
328
+ 'saveKey',
329
+ 'saveLabel',
330
+ 'saveProject',
331
+ 'setExpiry',
332
+ 'submitMount',
333
+ 'toggleService',
334
+ 'wizSubmitCfToken',
335
+ ];
336
+
337
+ function registeredWriteActions(source) {
338
+ const literal = source.match(/const WRITE_ACTIONS = \[([\s\S]*?)\];/);
339
+ assert.ok(literal, 'WRITE_ACTIONS registry must exist in the dashboard script');
340
+ return new Set([...literal[1].matchAll(/"([A-Za-z0-9_$]+)"/g)].map((m) => m[1]));
341
+ }
342
+
343
+ /**
344
+ * Every top-level dashboard function whose body calls writeHeaders().
345
+ *
346
+ * Recognises const/let/var function-expression and arrow bindings as well as
347
+ * function declarations. Without that, converting a write action to
348
+ * `const foo = async () => {…}` would attribute its writeHeaders() call to the
349
+ * PRECEDING declaration — and when that neighbour is itself registered, the
350
+ * call silently lands on an already-present name and this test passes on a
351
+ * genuinely unguarded action.
352
+ *
353
+ * Comment lines are skipped: prose mentioning writeHeaders() is not a call
354
+ * site, and counting it as one produced a false "bypasses the choke point"
355
+ * failure the first time a doc comment landed after a function declaration.
356
+ */
357
+ function writeHeaderCallers(source) {
358
+ const callers = new Set();
359
+ let current = null;
360
+ for (const line of source.split(/\r?\n/)) {
361
+ const decl = line.match(/^(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/);
362
+ const bound = line.match(
363
+ /^(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:function\b|\(|[A-Za-z0-9_$]+\s*=>)/,
364
+ );
365
+ if (decl) current = decl[1];
366
+ else if (bound) current = bound[1];
367
+
368
+ const code = line.trim();
369
+ if (code.startsWith('//') || code.startsWith('*') || code.startsWith('/*')) continue;
370
+ if (line.includes('writeHeaders(') && current && current !== 'writeHeaders') {
371
+ callers.add(current);
372
+ }
373
+ }
374
+ return callers;
375
+ }
376
+
377
+ test('WRITE_ACTIONS matches the reviewed write-action surface exactly', () => {
378
+ const registered = [...registeredWriteActions(serveSource)].sort();
379
+ assert.deepEqual(
380
+ registered,
381
+ EXPECTED_WRITE_ACTIONS,
382
+ 'WRITE_ACTIONS changed — update EXPECTED_WRITE_ACTIONS deliberately, and confirm the new action is a function declaration',
383
+ );
384
+ });
385
+
386
+ test('WRITE_ACTIONS covers every function that calls writeHeaders()', () => {
387
+ const registered = registeredWriteActions(serveSource);
388
+ const callers = writeHeaderCallers(serveSource);
389
+
390
+ const unguarded = [...callers].filter((n) => !registered.has(n));
391
+ assert.deepEqual(unguarded, [], 'these write actions bypass the choke point: ' + unguarded.join(', '));
392
+
393
+ const stale = [...registered].filter((n) => !callers.has(n));
394
+ assert.deepEqual(stale, [], 'these registry entries no longer write: ' + stale.join(', '));
395
+
396
+ assert.equal(callers.size, registered.size);
397
+ });
398
+
399
+ test('every registered write action is actually wrapped at install time', () => {
400
+ const h = bootDashboard({ initWriteToken: 'wt-preexisting', route: unlockedRoute });
401
+ const registered = [...registeredWriteActions(serveSource)];
402
+
403
+ const notWrapped = registered.filter((name) => h.ctx[name]?.__writeGuarded !== true);
404
+ assert.deepEqual(
405
+ notWrapped,
406
+ [],
407
+ 'registered but not guarded in the live realm (a const/arrow binding cannot be guarded): ' + notWrapped.join(', '),
408
+ );
409
+ assert.equal(registered.length, EXPECTED_WRITE_ACTIONS.length);
410
+ });
411
+
412
+ test('installWriteAccessGuards throws on a registered name that cannot be guarded', () => {
413
+ assert.match(serveSource, /clauth write-guard install failed/);
414
+ assert.doesNotMatch(
415
+ serveSource,
416
+ /if \(typeof fn !== "function" \|\| fn\.__writeGuarded\) continue;/,
417
+ 'the old silent-skip guard must not come back',
418
+ );
419
+ });
420
+
421
+ test('the write-access choke point is a single mechanism, not per-call-site guards', () => {
422
+ const parkSites = [...serveSource.matchAll(/pendingWriteAction = \{/g)];
423
+ assert.equal(parkSites.length, 1, 'exactly one place may park a write action');
424
+ const acquireSites = [...serveSource.matchAll(/BASE \+ "\/write-token"/g)];
425
+ assert.equal(acquireSites.length, 1, 'exactly one place may acquire write access');
426
+ assert.match(serveSource, /function installWriteAccessGuards\(/);
427
+ });
428
+
429
+ // ── 4. Server contract: the gate stays, the prompt goes ──────────────────────
430
+
431
+ test('POST /write-token refuses a locked vault and never weakens the write gate', () => {
432
+ assert.match(serveSource, /reqPath === "\/write-token"/, 'the acquire route must exist');
433
+ assert.match(
434
+ serveSource,
435
+ /reqPath === "\/write-token"\)\s*\{\s*\n\s*if \(!password\) \{/,
436
+ 'a locked vault must be refused before any token is minted',
437
+ );
438
+ // writeGuard is what makes a page-driven write distinguishable from a blind
439
+ // remote POST arriving through the cloudflared tunnel as loopback. It stays.
440
+ assert.match(serveSource, /function writeGuard\(req, res\) \{[\s\S]*?validateWriteToken\(req, writeSession\)/);
441
+ assert.match(serveSource, /if \(!isLocal\) \{[\s\S]*?strike\(res, 403/, 'non-loopback must stay hard-rejected');
442
+ });
443
+
444
+ // ── Pre-existing coverage — lifecycle actions and receipt feedback ───────────
445
+
10
446
  test('clauth supervisor UI exposes every lifecycle action and receipt feedback', () => {
11
447
  for (const action of ['start', 'stop', 'restart', 'reconcile', 'test', 'promote', 'rollback']) {
12
448
  assert.match(serveSource, new RegExp('"' + action + '"'));
@@ -178,7 +178,30 @@ export function readWatchdogEvents(limit = 100) {
178
178
  }
179
179
  }
180
180
 
181
- export function restartWatchdogService(id) {
181
+ async function verifyRestartHealth(service) {
182
+ if (!service.health?.url) return { ok: true, health_status: "not_configured" };
183
+
184
+ const attempts = Number(service.health.readyAttempts || 20);
185
+ const delayMs = Number(service.health.readyDelayMs || 250);
186
+ let observed;
187
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
188
+ observed = await evaluateWatchdogService(service);
189
+ if (observed.status === "healthy") {
190
+ return { ok: true, health_status: observed.status, health_http_status: observed.httpStatus, attempts: attempt };
191
+ }
192
+ if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs));
193
+ }
194
+ return {
195
+ ok: false,
196
+ error: "restart_health_unreachable",
197
+ health_status: observed?.status || "unknown",
198
+ health_http_status: observed?.httpStatus,
199
+ health_error: observed?.error,
200
+ attempts,
201
+ };
202
+ }
203
+
204
+ export async function restartWatchdogService(id) {
182
205
  const service = loadRegistry().services.find((candidate) => candidate.id === id);
183
206
  if (!service) return { ok: false, error: "service_not_registered" };
184
207
  if (!service.restart) return { ok: false, error: "restart_not_configured" };
@@ -192,18 +215,23 @@ export function restartWatchdogService(id) {
192
215
  encoding: "utf8",
193
216
  timeout: Number(service.restart.timeoutMs || 30000),
194
217
  });
218
+ const health = result.status === 0 ? await verifyRestartHealth(service) : { ok: false, health_status: "not_checked" };
195
219
  const event = {
196
220
  kind: "restart",
197
221
  service_id: id,
198
222
  status: result.status,
223
+ health_status: health.health_status,
224
+ health_http_status: health.health_http_status,
225
+ health_error: health.health_error,
199
226
  error: result.error ? result.error.message : undefined,
200
227
  };
201
228
  appendEvent(event);
202
229
  return {
203
- ok: result.status === 0,
230
+ ok: result.status === 0 && health.ok,
204
231
  status: result.status,
205
232
  stdout: result.stdout,
206
233
  stderr: result.stderr,
207
234
  error: result.error ? result.error.message : undefined,
235
+ ...health,
208
236
  };
209
237
  }
@@ -13,12 +13,12 @@ import {
13
13
  validateWatchdogService,
14
14
  } from "./watchdog-registry.js";
15
15
 
16
- function withTempRegistry(fn) {
16
+ async function withTempRegistry(fn) {
17
17
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-watchdog-"));
18
18
  const old = process.env.CLAUTH_WATCHDOG_DIR;
19
19
  process.env.CLAUTH_WATCHDOG_DIR = dir;
20
20
  try {
21
- return fn(dir);
21
+ return await fn(dir);
22
22
  } finally {
23
23
  if (old === undefined) delete process.env.CLAUTH_WATCHDOG_DIR;
24
24
  else process.env.CLAUTH_WATCHDOG_DIR = old;
@@ -78,12 +78,35 @@ test("registerWatchdogManifest upserts services by id", () => withTempRegistry((
78
78
  assert.equal(registry.services.find((service) => service.id === "codeflow").label, "CodeFlow Updated");
79
79
  }));
80
80
 
81
- test("restartWatchdogService rejects missing and unapproved services", () => withTempRegistry(() => {
82
- assert.deepEqual(restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
81
+ test("restartWatchdogService rejects missing and unapproved services", async () => withTempRegistry(async () => {
82
+ assert.deepEqual(await restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
83
83
  registerWatchdogManifest({
84
84
  services: [
85
85
  { id: "dev-center", label: "Dev Center", kind: "process", restart: { cmd: "node", args: ["--version"] } },
86
86
  ],
87
87
  });
88
- assert.deepEqual(restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
88
+ assert.deepEqual(await restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
89
+ }));
90
+
91
+ test("restartWatchdogService requires registered health after launching", async () => withTempRegistry(async () => {
92
+ registerWatchdogManifest({
93
+ services: [{
94
+ id: "health-gated",
95
+ label: "Health gated",
96
+ kind: "http",
97
+ health: { url: "http://127.0.0.1:3109/health", readyAttempts: 2, readyDelayMs: 0 },
98
+ restart: { cmd: process.execPath, args: ["--version"] },
99
+ approvalRequired: false,
100
+ }],
101
+ });
102
+ const originalFetch = globalThis.fetch;
103
+ globalThis.fetch = async () => ({ ok: false, status: 503 });
104
+ try {
105
+ const result = await restartWatchdogService("health-gated");
106
+ assert.equal(result.ok, false);
107
+ assert.equal(result.error, "restart_health_unreachable");
108
+ assert.equal(result.health_status, "degraded");
109
+ } finally {
110
+ globalThis.fetch = originalFetch;
111
+ }
89
112
  }));