@celilo/cli 0.15.0 → 0.16.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.
@@ -30,6 +30,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
30
30
  - **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
31
31
  - **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
32
32
  - **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
33
+ - **Daemon-journal diagnostic ("what does the module's daemon know that celilo doesn't?")** — `apps/celilo/src/services/module-journal.ts` + `apps/celilo/src/cli/commands/module-journal.ts` (`celilo module journal <id> [--unit|--lines|--since|--grep|--json]`, MCP `celilo_module_journal` on the RO principal) — resolves hosts via `getModuleSystems` and reads each one's systemd journal through the `tailLog` remote primitive. General to every module, not one. **READ-ONLY by construction**: `planJournalRead` is pure and the only remote command the op can emit is a `journalctl` read, so it cannot reconfigure a module, send anything, or consume inbound messages the collection path is waiting for (`module-journal.test.ts` asserts on the exact command reaching the SSH seam). Sibling to `celilo module logs`, which reads the LOCAL Ansible deploy log, not the remote daemon. Unit defaults to the glob `<module-id>*`. An unreachable host reports UNREADABLE, never as an empty journal. Rationale: openspec/changes/fix-signal-inbound-delivery Decision 5.
33
34
 
34
35
  ## Capability system (cross-module data & functions)
35
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,7 +59,7 @@
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
60
  "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
- "@celilo/core": "^0.2.0",
62
+ "@celilo/core": "^0.3.0",
63
63
  "@celilo/event-bus": "^0.1.9",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
@@ -11,7 +11,13 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
- import { handleModuleConfigSet } from './module-config';
14
+ import { resolveDeployPosture } from '../../services/deploy-posture';
15
+ import {
16
+ FRAMEWORK_CONFIG_KEYS,
17
+ handleModuleConfigSet,
18
+ validateFrameworkConfigValue,
19
+ } from './module-config';
20
+ import { pickUpgradePolicy } from './module-upgrade';
15
21
 
16
22
  describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
17
23
  let tempDir: string;
@@ -75,4 +81,74 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
75
81
  expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
76
82
  }
77
83
  });
84
+
85
+ // #515: these describe how celilo TREATS a module, so they must not depend on
86
+ // the module having declared them. `testmod` declares neither — which is the
87
+ // bug exactly: `upgrade_policy` was declared by NO module, so `always-safe`
88
+ // (the only control over unattended-upgrade risk) could never be set.
89
+ test('accepts upgrade_policy on a module that does not declare it', async () => {
90
+ const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'always-safe']);
91
+ expect(result.success).toBe(true);
92
+ });
93
+
94
+ test('accepts auto_upgrade on a module that does not declare it', async () => {
95
+ const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'true']);
96
+ expect(result.success).toBe(true);
97
+ });
98
+
99
+ test('rejects a mistyped policy instead of silently falling back to by-semver', async () => {
100
+ // `pickUpgradePolicy` fails OPEN: an unrecognized value becomes `by-semver`,
101
+ // which on a patch means fast posture and NO backup. Accepting this would
102
+ // leave the operator believing the safe floor was armed.
103
+ const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'alwayssafe']);
104
+ expect(result.success).toBe(false);
105
+ if (!result.success) {
106
+ expect(result.error).toContain('always-safe');
107
+ }
108
+ });
109
+
110
+ test('rejects a non-boolean auto_upgrade', async () => {
111
+ const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'yes']);
112
+ expect(result.success).toBe(false);
113
+ });
114
+
115
+ test('the valid-keys hint advertises the celilo-managed keys', async () => {
116
+ const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
117
+ expect(result.success).toBe(false);
118
+ if (!result.success) {
119
+ expect(result.error).toContain('upgrade_policy');
120
+ expect(result.error).toContain('auto_upgrade');
121
+ }
122
+ });
123
+ });
124
+
125
+ describe('validateFrameworkConfigValue (pure)', () => {
126
+ test('passes through non-framework keys untouched', () => {
127
+ expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull();
128
+ });
129
+
130
+ test('accepts every documented value', () => {
131
+ for (const [key, values] of Object.entries(FRAMEWORK_CONFIG_KEYS)) {
132
+ for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull();
133
+ }
134
+ });
135
+
136
+ test('rejects an unlisted value', () => {
137
+ expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed');
138
+ });
139
+ });
140
+
141
+ // The point of the whole control: with always-safe set, a PATCH upgrade — which
142
+ // by default is fast posture and skips the pre-deploy backup — becomes safe.
143
+ // #515 made this unreachable, so this asserts the chain end to end.
144
+ describe('always-safe actually changes posture on a patch (#515)', () => {
145
+ test('patch is fast by default, safe once always-safe is chosen', () => {
146
+ const patch = { installed: '1.0.3', next: '1.0.4' };
147
+ expect(
148
+ resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy(undefined) }).posture,
149
+ ).toBe('fast');
150
+ expect(
151
+ resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy('always-safe') }).posture,
152
+ ).toBe('safe');
153
+ });
78
154
  });
@@ -14,6 +14,42 @@ import {
14
14
  import { getArg, validateRequiredArgs } from '../parser';
15
15
  import type { CommandResult } from '../types';
16
16
 
17
+ /**
18
+ * Operator keys that EVERY module accepts, whether or not its manifest declares
19
+ * them, with their permitted values.
20
+ *
21
+ * These describe how celilo TREATS a module (its CD policy), not how the module
22
+ * configures itself, so gating them on `variables.owns` had it backwards: it
23
+ * required each module author to opt into being manageable. The failure was
24
+ * silent — `upgrade_policy` was declared by no module at all, so
25
+ * `pickUpgradePolicy()` could only ever read `undefined` and fall back to
26
+ * `by-semver`, leaving `always-safe` (the ONLY control over unattended-upgrade
27
+ * risk) permanently unreachable. `auto_upgrade` worked only because lunacycle
28
+ * happened to declare it. See #515.
29
+ *
30
+ * Values are checked at SET time rather than coerced at read time. Both readers
31
+ * fail OPEN on an unrecognized value — `pickUpgradePolicy()` returns
32
+ * `by-semver`, which for a patch means fast posture and NO backup. So a typo
33
+ * like `alwayssafe` would leave the operator believing they had armed the safe
34
+ * floor while nothing changed. A safety control that fails open on a typo is
35
+ * worse than no control.
36
+ */
37
+ export const FRAMEWORK_CONFIG_KEYS: Record<string, readonly string[]> = {
38
+ auto_upgrade: ['true', 'false'],
39
+ upgrade_policy: ['by-semver', 'always-safe', 'always-fast'],
40
+ };
41
+
42
+ /**
43
+ * PURE (Rule 10.1): validate a framework key's value. Returns an error message,
44
+ * or null when the key is not a framework key or the value is permitted.
45
+ */
46
+ export function validateFrameworkConfigValue(key: string, value: string): string | null {
47
+ const allowed = FRAMEWORK_CONFIG_KEYS[key];
48
+ if (!allowed) return null;
49
+ if (allowed.includes(value)) return null;
50
+ return `Invalid value '${value}' for '${key}'.\n\nAllowed: ${allowed.join(', ')}\n\nRejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.`;
51
+ }
52
+
17
53
  /**
18
54
  * Handle module config set command
19
55
  *
@@ -54,6 +90,11 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
54
90
  };
55
91
  }
56
92
 
93
+ // Framework keys bypass the manifest check entirely — see FRAMEWORK_CONFIG_KEYS.
94
+ const frameworkError = validateFrameworkConfigValue(key, value);
95
+ if (frameworkError) return { success: false, error: frameworkError };
96
+ const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
97
+
57
98
  // Validate key against manifest
58
99
  const manifest = module.manifestData as Record<string, unknown>;
59
100
  const variables = manifest.variables as
@@ -63,14 +104,15 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
63
104
 
64
105
  // Check if key is declared in manifest
65
106
  const declaredVar = declaredVars.find((v) => v.name === key);
66
- if (!declaredVar) {
107
+ if (!declaredVar && !isFrameworkKey) {
67
108
  const settableKeys = declaredVars
68
109
  .filter((v) => v.source !== 'infrastructure')
69
110
  .map((v) => v.name)
70
111
  .join(', ');
112
+ const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
71
113
  return {
72
114
  success: false,
73
- error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
115
+ error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}\nCelilo-managed keys (any module): ${frameworkKeys}`,
74
116
  };
75
117
  }
76
118
 
@@ -79,7 +121,7 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
79
121
  // variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
80
122
  // derived by the deploy (IPAM allocates vmid/IP; the container service supplies
81
123
  // node/template/gateway/vlan), so a value set here would be ignored.
82
- if (declaredVar.source === 'infrastructure') {
124
+ if (declaredVar?.source === 'infrastructure') {
83
125
  return {
84
126
  success: false,
85
127
  error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { JournalReport } from '../../services/module-journal';
3
+ import { formatJournalReport } from './module-journal';
4
+
5
+ const system = (over: Partial<JournalReport['systems'][number]> = {}) => ({
6
+ system: 'signal',
7
+ hostname: 'signal',
8
+ ipv4Address: '10.0.20.40',
9
+ unit: 'signal*',
10
+ ok: true,
11
+ lines: [] as string[],
12
+ ...over,
13
+ });
14
+
15
+ describe('formatJournalReport', () => {
16
+ test('renders the journal lines under their host', () => {
17
+ const { message, allOk } = formatJournalReport({
18
+ moduleId: 'signal',
19
+ systems: [system({ lines: ['Received sync sent message'] })],
20
+ });
21
+ expect(allOk).toBe(true);
22
+ expect(message).toContain('signal (10.0.20.40)');
23
+ expect(message).toContain('Received sync sent message');
24
+ });
25
+
26
+ // The defect class of #501: unreadable must not present as quiet.
27
+ test('an unreadable host and an empty journal do not look the same', () => {
28
+ const empty = formatJournalReport({ moduleId: 'signal', systems: [system()] });
29
+ const broken = formatJournalReport({
30
+ moduleId: 'signal',
31
+ systems: [system({ ok: false, error: 'Connection timed out' })],
32
+ });
33
+
34
+ expect(empty.allOk).toBe(true);
35
+ expect(broken.allOk).toBe(false);
36
+ expect(broken.message).not.toBe(empty.message);
37
+ expect(broken.message).toContain('UNREADABLE');
38
+ });
39
+
40
+ test('one unreadable host among several fails the whole read', () => {
41
+ const { allOk } = formatJournalReport({
42
+ moduleId: 'forgejo',
43
+ systems: [system({ lines: ['ok'] }), system({ ok: false, error: 'no route to host' })],
44
+ });
45
+ expect(allOk).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Module journal command — read a deployed module's runtime daemon logs
3
+ * (`journalctl -u <unit>`) through celilo, without SSH to the host.
4
+ *
5
+ * Sibling to `celilo module logs`, not an extension of it: `logs` reads the
6
+ * LOCAL deploy log written by Ansible on celilo-mgr, `journal` reads the
7
+ * REMOTE daemon's journal on the host that runs the module. Different source,
8
+ * different failure modes (a host can be unreachable; a log file cannot), and
9
+ * different answers to different questions. Folding them together would have
10
+ * meant one command whose meaning depended on a flag.
11
+ *
12
+ * Read-only by construction — see services/module-journal.ts.
13
+ */
14
+
15
+ import { getDb } from '../../db/client';
16
+ import { type JournalReport, readModuleJournal } from '../../services/module-journal';
17
+ import { getArg, hasFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+
20
+ const USAGE =
21
+ 'Usage: celilo module journal <id> [--unit <name>] [--lines <n>] [--since <when>] [--grep <pattern>] [--json]';
22
+
23
+ function numericFlag(value: string | boolean | undefined): number | undefined {
24
+ return typeof value === 'string' ? Number(value) : undefined;
25
+ }
26
+
27
+ function stringFlag(value: string | boolean | undefined): string | undefined {
28
+ return typeof value === 'string' ? value : undefined;
29
+ }
30
+
31
+ /**
32
+ * Handle module journal command.
33
+ */
34
+ export async function handleModuleJournal(
35
+ args: string[],
36
+ flags: Record<string, string | boolean> = {},
37
+ ): Promise<CommandResult> {
38
+ const argError = validateRequiredArgs(args, 1);
39
+ if (argError) {
40
+ return { success: false, error: `${argError}\n\n${USAGE}` };
41
+ }
42
+
43
+ const moduleId = getArg(args, 0);
44
+ if (!moduleId) {
45
+ return { success: false, error: 'Module ID is required' };
46
+ }
47
+
48
+ const report = readModuleJournal(
49
+ {
50
+ moduleId,
51
+ unit: stringFlag(flags.unit),
52
+ lines: numericFlag(flags.lines),
53
+ since: stringFlag(flags.since),
54
+ grep: stringFlag(flags.grep),
55
+ },
56
+ getDb(),
57
+ );
58
+
59
+ if ('error' in report) {
60
+ return { success: false, error: report.error };
61
+ }
62
+
63
+ if (hasFlag(flags, 'json')) {
64
+ return {
65
+ success: true,
66
+ message: JSON.stringify(report, null, 2),
67
+ rawOutput: true,
68
+ data: report,
69
+ };
70
+ }
71
+
72
+ const { message, allOk } = formatJournalReport(report);
73
+ return allOk ? { success: true, message } : { success: false, error: message };
74
+ }
75
+
76
+ /**
77
+ * Presentation. A host celilo could not read is a FAILURE, not a quiet success
78
+ * — the defect this whole change exists to fix is unreadable and empty being
79
+ * byte-identical to the operator.
80
+ */
81
+ export function formatJournalReport(report: JournalReport): { message: string; allOk: boolean } {
82
+ const out: string[] = [];
83
+ for (const sys of report.systems) {
84
+ out.push(`── ${sys.hostname} (${sys.ipv4Address}) · unit ${sys.unit}`);
85
+ if (!sys.ok) {
86
+ out.push(` ✗ UNREADABLE: ${sys.error}`);
87
+ } else if (sys.lines.length === 0) {
88
+ out.push(' (no matching journal lines)');
89
+ } else {
90
+ out.push(...sys.lines.map((line) => ` ${line}`));
91
+ }
92
+ out.push('');
93
+ }
94
+ return {
95
+ message: out.join('\n').trimEnd(),
96
+ allOk: report.systems.every((s) => s.ok),
97
+ };
98
+ }
@@ -181,6 +181,7 @@ export async function getCompletions(words: string[], current: number): Promise<
181
181
  'destroy',
182
182
  'health',
183
183
  'logs',
184
+ 'journal',
184
185
  'run-hook',
185
186
  'secret',
186
187
  'status',
@@ -423,6 +424,7 @@ export async function getCompletions(words: string[], current: number): Promise<
423
424
  'deploy',
424
425
  'destroy',
425
426
  'logs',
427
+ 'journal',
426
428
  'remove',
427
429
  'build',
428
430
  'backup',
@@ -324,6 +324,10 @@ _celilo_config_keys() {
324
324
  fi
325
325
  config_keys+=("$key:$desc_text")
326
326
  done < <(echo "$manifest_json" | jq -r '.variables.owns[]? | "\\(.name)|\\(.description // "No description")|\\(.required // false)"' 2>/dev/null)
327
+ # Celilo-managed keys apply to EVERY module regardless of its manifest
328
+ # (#515), so offer them even when the manifest declares none.
329
+ config_keys+=("auto_upgrade:Let the registry poll upgrade this module unattended (true|false)")
330
+ config_keys+=("upgrade_policy:Deploy posture floor for upgrades (by-semver|always-safe|always-fast)")
327
331
  if [[ \${#config_keys[@]} -gt 0 ]]; then
328
332
  _describe 'config key' config_keys
329
333
  return 0
package/src/cli/index.ts CHANGED
@@ -64,6 +64,7 @@ import { handleModuleDeploy } from './commands/module-deploy';
64
64
  import { handleModuleGenerate } from './commands/module-generate';
65
65
  import { handleModuleHealth } from './commands/module-health';
66
66
  import { handleModuleImport } from './commands/module-import';
67
+ import { handleModuleJournal } from './commands/module-journal';
67
68
  import { handleModuleList } from './commands/module-list';
68
69
  import { handleModuleLogs } from './commands/module-logs';
69
70
  import { handleModuleOperations } from './commands/module-operations';
@@ -1468,6 +1469,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1468
1469
  return handleModuleWhere(parsed.args, parsed.flags);
1469
1470
  case 'logs':
1470
1471
  return handleModuleLogs(parsed.args, parsed.flags);
1472
+ case 'journal':
1473
+ return handleModuleJournal(parsed.args, parsed.flags);
1471
1474
  case 'health':
1472
1475
  return handleModuleHealth(parsed.args, parsed.flags);
1473
1476
  case 'operations':
@@ -118,6 +118,7 @@ describe('pollInbound', () => {
118
118
  });
119
119
 
120
120
  const alertState = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.state;
121
+ const ackedBy = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.ackedBy;
121
122
 
122
123
  test('only transports with routes are polled', () => {
123
124
  expect(transportsWithRoutes(db)).toEqual(['signal']);
@@ -326,9 +327,199 @@ describe('pollInbound', () => {
326
327
  expect(alertState()).toBe('firing');
327
328
  });
328
329
 
330
+ // The seam that was untested. `acknowledgeAlert` returning null was covered
331
+ // (ack.test.ts), and pollInbound was covered — but not what the REPORT says
332
+ // when the two meet. Observed live: a reply produced `1 acked` while every
333
+ // alert still read `ackedBy: null`, because the counter incremented next to
334
+ // the call instead of observing its result.
335
+ describe('a token whose alert no longer exists', () => {
336
+ test('is NOT counted as acked', async () => {
337
+ const delivery = page(peterRoute.id);
338
+ db.delete(alerts).run(); // the alert this token names is gone
339
+
340
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
341
+
342
+ expect(report.acked).toBe(0);
343
+ });
344
+
345
+ test('is reported as stale_target rather than silently', async () => {
346
+ const delivery = page(peterRoute.id);
347
+ db.delete(alerts).run();
348
+
349
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
350
+
351
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'stale_target' }]);
352
+ });
353
+
354
+ // The token is still single-use: a dead target must not leave it replayable.
355
+ test('still consumes the token', async () => {
356
+ const delivery = page(peterRoute.id);
357
+ db.delete(alerts).run();
358
+
359
+ await pollInbound(db, deps([inbound(PETER, delivery.token)]));
360
+ const second = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
361
+
362
+ expect(second.acked).toBe(0);
363
+ expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unknown_token' }]);
364
+ });
365
+
366
+ // Nobody should be told "someone took it" when nobody did.
367
+ test('broadcasts nothing', async () => {
368
+ const delivery = page(peterRoute.id);
369
+ db.delete(alerts).run();
370
+
371
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
372
+
373
+ expect(report.broadcast).toBe(0);
374
+ });
375
+ });
376
+
377
+ // The counter must track the database, not the attempt. If these two ever
378
+ // disagree again, the operator sees a success that did not happen.
379
+ test('report.acked agrees with what the alert row actually says', async () => {
380
+ const delivery = page(peterRoute.id);
381
+
382
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
383
+
384
+ expect(report.acked).toBe(1);
385
+ expect(alertState()).toBe('acked');
386
+ expect(ackedBy()).toBe(peterRoute.personId);
387
+ });
388
+
329
389
  test('a route pointing at a transport nobody uses is not polled', () => {
330
390
  db.delete(alerts).run();
331
391
  expect(transportsWithRoutes(db)).toEqual(['signal']);
332
392
  expect(wifeRoute.transportModuleId).toBe('signal');
333
393
  });
394
+
395
+ /**
396
+ * Answering a deploy question, at the SEAM.
397
+ *
398
+ * This is the coverage #533 was missing. `parseInterviewAnswer` was unit
399
+ * tested and correct; `pollInbound` was unit tested and correct; and an
400
+ * answer sent in the shape the page asks for was rejected as `unrecognised`
401
+ * for months, because nothing exercised an interview delivery THROUGH the
402
+ * poller. Two green units, wrong wiring — the same shape as the ack counter
403
+ * above.
404
+ */
405
+ describe('an answer to a deploy question', () => {
406
+ const ask = (routeId: string, eventId = '42') =>
407
+ mintDelivery(db, {
408
+ kind: 'interview',
409
+ targetId: eventId,
410
+ routeId,
411
+ now: NOW,
412
+ ttlMs: 60_000,
413
+ });
414
+
415
+ test('is published against the waiting question and counted as answered', async () => {
416
+ const question = ask(peterRoute.id, '42');
417
+ const answered: { eventId: string; value: string }[] = [];
418
+
419
+ const report = await pollInbound(
420
+ db,
421
+ deps([inbound(PETER, `${question.token} admin@example.org`)], {
422
+ answerInterview: (eventId, value) => answered.push({ eventId, value }),
423
+ }),
424
+ );
425
+
426
+ expect(answered).toEqual([{ eventId: '42', value: 'admin@example.org' }]);
427
+ expect(report.answered).toBe(1);
428
+ // An answer is not an acknowledgement; nothing about an alert moved.
429
+ expect(report.acked).toBe(0);
430
+ expect(report.unheard).toEqual([]);
431
+ });
432
+
433
+ test('a value containing spaces survives verbatim', async () => {
434
+ const question = ask(peterRoute.id);
435
+ const answered: string[] = [];
436
+
437
+ await pollInbound(
438
+ db,
439
+ deps([inbound(PETER, `${question.token} my value: with, punctuation`)], {
440
+ answerInterview: (_id, value) => answered.push(value),
441
+ }),
442
+ );
443
+
444
+ expect(answered).toEqual(['my value: with, punctuation']);
445
+ });
446
+
447
+ test('the token is consumed, so an answer cannot be replayed', async () => {
448
+ const question = ask(peterRoute.id);
449
+ const answered: string[] = [];
450
+ const withResponder = (body: string) =>
451
+ pollInbound(
452
+ db,
453
+ deps([inbound(PETER, body)], { answerInterview: (_id, v) => answered.push(v) }),
454
+ );
455
+
456
+ await withResponder(`${question.token} first`);
457
+ const second = await withResponder(`${question.token} second`);
458
+
459
+ expect(answered).toEqual(['first']);
460
+ expect(second.answered).toBe(0);
461
+ // `unrecognised`, not `unknown_token`: once the token no longer resolves,
462
+ // `<word> <text>` is indistinguishable from an ordinary sentence, so
463
+ // celilo declines to assert the operator mistyped a token.
464
+ expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
465
+ });
466
+
467
+ // Naming the question but supplying nothing is its own answer, and the
468
+ // token must survive so the operator's next attempt can work.
469
+ test('a token with no value reports needs_value and leaves the token live', async () => {
470
+ const question = ask(peterRoute.id);
471
+
472
+ const first = await pollInbound(
473
+ db,
474
+ deps([inbound(PETER, question.token)], { answerInterview: () => {} }),
475
+ );
476
+ expect(first.answered).toBe(0);
477
+ expect(first.unheard).toEqual([{ senderAddress: PETER, reason: 'needs_value' }]);
478
+
479
+ const answered: string[] = [];
480
+ const second = await pollInbound(
481
+ db,
482
+ deps([inbound(PETER, `${question.token} admin@example.org`)], {
483
+ answerInterview: (_id, v) => answered.push(v),
484
+ }),
485
+ );
486
+ expect(second.answered).toBe(1);
487
+ expect(answered).toEqual(['admin@example.org']);
488
+ });
489
+
490
+ // With no responder attached there is nothing to publish against, so the
491
+ // question stays unanswered AND the token stays usable.
492
+ test('with no responder attached the token is not burned', async () => {
493
+ const question = ask(peterRoute.id);
494
+
495
+ const report = await pollInbound(db, deps([inbound(PETER, `${question.token} value`)]));
496
+ expect(report.answered).toBe(0);
497
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
498
+
499
+ const answered: string[] = [];
500
+ const retry = await pollInbound(
501
+ db,
502
+ deps([inbound(PETER, `${question.token} value`)], {
503
+ answerInterview: (_id, v) => answered.push(v),
504
+ }),
505
+ );
506
+ expect(retry.answered).toBe(1);
507
+ expect(answered).toEqual(['value']);
508
+ });
509
+
510
+ test('an answer from a number the question was not sent to is refused', async () => {
511
+ const question = ask(peterRoute.id);
512
+ const answered: string[] = [];
513
+
514
+ const report = await pollInbound(
515
+ db,
516
+ deps([inbound(WIFE, `${question.token} admin@example.org`)], {
517
+ answerInterview: (_id, v) => answered.push(v),
518
+ }),
519
+ );
520
+
521
+ expect(answered).toEqual([]);
522
+ expect(report.unheard).toEqual([{ senderAddress: WIFE, reason: 'wrong_sender' }]);
523
+ });
524
+ });
334
525
  });