@celilo/cli 0.14.4 → 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.
Files changed (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +19 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +3 -3
  6. package/src/cli/commands/alerts-list.ts +10 -0
  7. package/src/cli/commands/alerts-poll.ts +12 -6
  8. package/src/cli/commands/alerts-sweep.ts +22 -81
  9. package/src/cli/commands/backup-sweep.ts +65 -0
  10. package/src/cli/commands/module-config.test.ts +77 -1
  11. package/src/cli/commands/module-config.ts +45 -3
  12. package/src/cli/commands/module-journal.test.ts +47 -0
  13. package/src/cli/commands/module-journal.ts +98 -0
  14. package/src/cli/commands/module-operations.test.ts +93 -0
  15. package/src/cli/commands/module-operations.ts +134 -0
  16. package/src/cli/commands/module-upgrade.test.ts +32 -20
  17. package/src/cli/commands/module-upgrade.ts +37 -32
  18. package/src/cli/commands/monitor.ts +26 -6
  19. package/src/cli/commands/system-audit.ts +3 -30
  20. package/src/cli/completion.ts +20 -1
  21. package/src/cli/generate-zsh-completion.ts +4 -0
  22. package/src/cli/index.ts +14 -0
  23. package/src/db/schema.ts +5 -3
  24. package/src/manifest/schema.ts +4 -1
  25. package/src/module/packaging/build.ts +4 -0
  26. package/src/services/alerting/builtin-source.ts +17 -2
  27. package/src/services/alerting/delivery-loop.test.ts +5 -1
  28. package/src/services/alerting/format.test.ts +0 -1
  29. package/src/services/alerting/inbound-poller.test.ts +235 -8
  30. package/src/services/alerting/inbound-poller.ts +95 -34
  31. package/src/services/alerting/inbound.test.ts +213 -2
  32. package/src/services/alerting/inbound.ts +161 -32
  33. package/src/services/alerting/interview-responder.test.ts +0 -32
  34. package/src/services/alerting/interview-responder.ts +6 -17
  35. package/src/services/alerting/notify-deps.ts +113 -0
  36. package/src/services/alerting/run-monitor.ts +0 -1
  37. package/src/services/alerting/store.test.ts +1 -1
  38. package/src/services/alerting/store.ts +0 -2
  39. package/src/services/alerting/sweep-runner.test.ts +11 -2
  40. package/src/services/alerting/sweep-runner.ts +14 -7
  41. package/src/services/alerting/tokens.ts +39 -1
  42. package/src/services/audit/backup-source.ts +54 -0
  43. package/src/services/audit/backups.test.ts +7 -2
  44. package/src/services/audit/backups.ts +10 -18
  45. package/src/services/backup-cipher.test.ts +188 -0
  46. package/src/services/backup-cipher.ts +178 -0
  47. package/src/services/backup-create.ts +20 -30
  48. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  49. package/src/services/backup-restore.ts +10 -16
  50. package/src/services/backup-schedule.ts +35 -0
  51. package/src/services/backup-sweep.test.ts +148 -0
  52. package/src/services/backup-sweep.ts +124 -0
  53. package/src/services/deploy-posture.ts +15 -2
  54. package/src/services/module-journal.test.ts +302 -0
  55. package/src/services/module-journal.ts +160 -0
  56. package/src/services/module-operations.test.ts +67 -6
  57. package/src/services/module-operations.ts +69 -19
  58. package/src/services/module-subscriptions.test.ts +33 -2
  59. package/src/services/module-subscriptions.ts +10 -1
  60. package/src/services/module-validator/typescript-build.test.ts +20 -1
  61. package/src/services/module-validator/typescript-build.ts +9 -5
  62. package/src/services/restore-from-file.ts +6 -21
  63. package/src/templates/generator.test.ts +88 -0
  64. package/src/templates/generator.ts +119 -16
@@ -20,12 +20,11 @@ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { promisify } from 'node:util';
23
- import { eq, isNotNull } from 'drizzle-orm';
23
+ import { isNotNull } from 'drizzle-orm';
24
24
  import { testDigitalOceanConnection } from '../../api-clients/digitalocean';
25
25
  import { testProxmoxConnection } from '../../api-clients/proxmox';
26
26
  import { getDb } from '../../db/client';
27
27
  import {
28
- backups,
29
28
  capabilitySecrets,
30
29
  moduleConfigs as moduleConfigsTbl,
31
30
  modules,
@@ -38,6 +37,7 @@ import { decryptSecret } from '../../secrets/encryption';
38
37
  import { getOrCreateMasterKey } from '../../secrets/master-key';
39
38
  import { runAudit } from '../../services/audit';
40
39
  import type { DriftFinding, SystemAuditReport } from '../../services/audit';
40
+ import { loadBackupAuditInfo } from '../../services/audit/backup-source';
41
41
  import {
42
42
  type LatestCliVersionFetcher,
43
43
  fetchLatestCliVersion,
@@ -154,28 +154,6 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
154
154
 
155
155
  const registryClient = new RegistryClient();
156
156
 
157
- // Most recent successful backup per module.
158
- // The `backups` table is added via inline ALTER statements in db/client.ts
159
- // for upgraded databases; a freshly-initialized DB created from the
160
- // drizzle journal alone may not have it yet. Treat absence as "no
161
- // backups recorded" rather than crashing the audit.
162
- const latestBackupByModule = new Map<string, number>();
163
- try {
164
- const successfulBackups = db
165
- .select()
166
- .from(backups)
167
- .where(eq(backups.status, 'completed'))
168
- .all();
169
- for (const b of successfulBackups) {
170
- if (!b.moduleId || !b.completedAt) continue;
171
- const ts = b.completedAt.getTime();
172
- const prev = latestBackupByModule.get(b.moduleId);
173
- if (prev === undefined || ts > prev) latestBackupByModule.set(b.moduleId, ts);
174
- }
175
- } catch {
176
- // backups table missing — leave map empty
177
- }
178
-
179
157
  // Build per-module config map for module-configs check.
180
158
  const allConfigs = db
181
159
  .select()
@@ -196,12 +174,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
196
174
  configs: configsByModule.get(m.id) ?? {},
197
175
  }));
198
176
 
199
- const installedBackupInfo = deployedModules.map((m) => ({
200
- id: m.id,
201
- state: m.state,
202
- manifest: m.manifestData as ModuleManifest,
203
- lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
204
- }));
177
+ const installedBackupInfo = loadBackupAuditInfo(db);
205
178
 
206
179
  // Compose per-module TF_VAR_* env vars in parallel — each call hits
207
180
  // the secret store / DB so they're not free, but they're all
@@ -181,10 +181,12 @@ 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',
187
188
  'where',
189
+ 'operations',
188
190
  'terraform-unlock',
189
191
  'types',
190
192
  'validate',
@@ -198,6 +200,12 @@ export async function getCompletions(words: string[], current: number): Promise<
198
200
  return filterSuggestions(subcommands, args[2] || '');
199
201
  }
200
202
 
203
+ // Module operations subcommands (celilo module operations list/clear)
204
+ if (command === 'module' && args[1] === 'operations' && currentIndex === 2) {
205
+ const subcommands = ['list', 'clear'];
206
+ return filterSuggestions(subcommands, args[2] || '');
207
+ }
208
+
201
209
  // Module types subcommands (celilo module types generate/check)
202
210
  if (command === 'module' && args[1] === 'types' && currentIndex === 2) {
203
211
  const subcommands = ['generate', 'check'];
@@ -416,6 +424,7 @@ export async function getCompletions(words: string[], current: number): Promise<
416
424
  'deploy',
417
425
  'destroy',
418
426
  'logs',
427
+ 'journal',
419
428
  'remove',
420
429
  'build',
421
430
  'backup',
@@ -500,7 +509,17 @@ export async function getCompletions(words: string[], current: number): Promise<
500
509
 
501
510
  // Backup subcommands
502
511
  if (command === 'backup' && currentIndex === 1) {
503
- const subcommands = ['create', 'list', 'restore', 'delete', 'prune', 'name', 'import', 'pull'];
512
+ const subcommands = [
513
+ 'create',
514
+ 'sweep',
515
+ 'list',
516
+ 'restore',
517
+ 'delete',
518
+ 'prune',
519
+ 'name',
520
+ 'import',
521
+ 'pull',
522
+ ];
504
523
  return filterSuggestions(subcommands, args[1] || '');
505
524
  }
506
525
 
@@ -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,8 +64,10 @@ 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';
70
+ import { handleModuleOperations } from './commands/module-operations';
69
71
  import { handleModulePublish } from './commands/module-publish';
70
72
  import { handleModuleRemove } from './commands/module-remove';
71
73
  import { handleModuleSearch } from './commands/module-search';
@@ -747,6 +749,9 @@ Subcommands:
747
749
  --storage <id> Use specific storage destination
748
750
  --no-interactive Non-interactive mode (for cron)
749
751
 
752
+ sweep Back up every module whose declared schedule is due
753
+ (run by the event bus on timer.tick.1h, not by hand)
754
+
750
755
  list [module-id] List available backups
751
756
  Options:
752
757
  --limit <n> Number of backups to show (default: 20)
@@ -1464,8 +1469,12 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1464
1469
  return handleModuleWhere(parsed.args, parsed.flags);
1465
1470
  case 'logs':
1466
1471
  return handleModuleLogs(parsed.args, parsed.flags);
1472
+ case 'journal':
1473
+ return handleModuleJournal(parsed.args, parsed.flags);
1467
1474
  case 'health':
1468
1475
  return handleModuleHealth(parsed.args, parsed.flags);
1476
+ case 'operations':
1477
+ return handleModuleOperations(parsed.args, parsed.flags);
1469
1478
  case 'remove':
1470
1479
  return handleModuleRemove(parsed.args, parsed.flags);
1471
1480
  case 'update':
@@ -1864,6 +1873,11 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1864
1873
  return handleBackupCreate(parsed.args, parsed.flags);
1865
1874
  }
1866
1875
 
1876
+ if (parsed.subcommand === 'sweep') {
1877
+ const { handleBackupSweep } = await import('./commands/backup-sweep');
1878
+ return handleBackupSweep();
1879
+ }
1880
+
1867
1881
  if (parsed.subcommand === 'list') {
1868
1882
  const { handleBackupList } = await import('./commands/backup-list');
1869
1883
  return handleBackupList(parsed.args, parsed.flags);
package/src/db/schema.ts CHANGED
@@ -1048,9 +1048,11 @@ export const alerts = sqliteTable(
1048
1048
  deferredRouteId: text('deferred_route_id').references(() => routes.id, {
1049
1049
  onDelete: 'set null',
1050
1050
  }),
1051
- escalationPolicyId: text('escalation_policy_id').references(() => escalationPolicies.id, {
1052
- onDelete: 'set null',
1053
- }),
1051
+ // No escalation policy is stored here on purpose. It lives on the MONITOR
1052
+ // and is resolved fresh on every sweep, so assigning a policy takes effect
1053
+ // on alerts that are already firing. Snapshotting it at alert creation made
1054
+ // `escalation-policy assign` a no-op for exactly the alert an operator was
1055
+ // trying to route — the one already paging nobody (#481).
1054
1056
  message: text('message').notNull(),
1055
1057
  details: text('details'),
1056
1058
  resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
@@ -694,7 +694,10 @@ export const ModuleManifestSchema = z
694
694
 
695
695
  backup: z
696
696
  .object({
697
- schedule: z.enum(['hourly', 'daily', 'weekly', 'monthly', 'manual']).default('manual'),
697
+ // Absent means `daily`. Opting out of backups entirely is a real
698
+ // decision and takes an explicit `manual` — see
699
+ // [[services/backup-schedule.ts]].
700
+ schedule: z.enum(['hourly', 'daily', 'weekly', 'monthly', 'manual']).default('daily'),
698
701
  retention: z
699
702
  .object({
700
703
  count: z.number().int().positive().default(7),
@@ -59,6 +59,10 @@ const EXCLUDE_PATTERNS = [
59
59
  '.DS_Store',
60
60
  '*.netapp',
61
61
  '*.test.ts',
62
+ // Dev-only, same bucket as the tests: scripts/tsconfig.json exists so tsc can
63
+ // check hooks in CI. Nothing on a target ever runs tsc, and shipping it would
64
+ // make every module's packaged content change whenever the shared base moves.
65
+ 'tsconfig.json',
62
66
  'checksums.json',
63
67
  'signature.sig',
64
68
  ];
@@ -12,22 +12,37 @@
12
12
  * as "nothing is wrong".
13
13
  */
14
14
 
15
+ import type { DbClient } from '../../db/client';
16
+ import { loadBackupAuditInfo } from '../audit/backup-source';
17
+ import { auditBackups } from '../audit/backups';
15
18
  import { auditMachinesReachable } from '../audit/machines-reachable';
16
19
  import type { DriftCategory, DriftFinding } from '../audit/types';
17
20
  import { probeMachines } from '../machine-probe';
18
21
 
19
22
  /** Categories a monitor can currently schedule. */
20
- export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = ['machines_reachable'];
23
+ export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
24
+ 'machines_reachable',
25
+ 'backups',
26
+ ];
21
27
 
22
28
  export function isSchedulableBuiltin(category: string): category is DriftCategory {
23
29
  return (SCHEDULABLE_BUILTIN_CHECKS as readonly string[]).includes(category);
24
30
  }
25
31
 
26
- export async function runBuiltinCheckForMonitor(category: DriftCategory): Promise<DriftFinding[]> {
32
+ export async function runBuiltinCheckForMonitor(
33
+ category: DriftCategory,
34
+ db: DbClient,
35
+ ): Promise<DriftFinding[]> {
27
36
  if (category === 'machines_reachable') {
28
37
  return auditMachinesReachable({ results: await probeMachines() });
29
38
  }
30
39
 
40
+ // Local DB reads only — cheap enough to run on every sweep, which is
41
+ // the whole reason this category is schedulable and most are not.
42
+ if (category === 'backups') {
43
+ return auditBackups({ modules: loadBackupAuditInfo(db) });
44
+ }
45
+
31
46
  throw new Error(
32
47
  `Built-in check "${category}" is not schedulable yet. Schedulable: ${SCHEDULABLE_BUILTIN_CHECKS.join(', ')}.`,
33
48
  );
@@ -68,6 +68,11 @@ beforeAll(async () => {
68
68
  ...process.env,
69
69
  SIGNAL_RPC_PORT: String(PORT),
70
70
  SIGNAL_KNOWN_RECIPIENTS: `${PETER},${WIFE}`,
71
+ // The flag the module's systemd unit must pass. The simulator defaults
72
+ // to signal-cli's real default (`on-start`), where the daemon drains
73
+ // replies into its own SSE stream and REFUSES `receive` — so a loop test
74
+ // may only read replies from a daemon started the way celilo deploys it.
75
+ SIGNAL_RECEIVE_MODE: 'manual',
71
76
  },
72
77
  stdout: 'pipe',
73
78
  stderr: 'pipe',
@@ -169,7 +174,6 @@ describe('delivery loop against the signal-cli simulator', () => {
169
174
  silencedUntil: null,
170
175
  escalationStep: 0,
171
176
  nextEscalationAt: null,
172
- escalationPolicyId: null,
173
177
  message: '/var 94% used',
174
178
  details: null,
175
179
  resolvedAt: null,
@@ -34,7 +34,6 @@ function alert(over: Partial<Alert> = {}): Alert {
34
34
  silencedUntil: null,
35
35
  escalationStep: 0,
36
36
  nextEscalationAt: null,
37
- escalationPolicyId: null,
38
37
  message: '/var 94% used',
39
38
  details: null,
40
39
  resolvedAt: null,
@@ -27,7 +27,7 @@ describe('pollInbound', () => {
27
27
 
28
28
  function deps(messages: InboundMessage[], over: Partial<InboundPollDeps> = {}): InboundPollDeps {
29
29
  return {
30
- receiveFrom: async () => ({ messages, cursor: 'cursor-1' }),
30
+ receiveFrom: async () => ({ status: 'received', messages, cursor: 'cursor-1' }),
31
31
  readCursor: (t) => cursors.get(t) ?? null,
32
32
  writeCursor: (t, c) => {
33
33
  cursors.set(t, c);
@@ -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']);
@@ -190,7 +191,7 @@ describe('pollInbound', () => {
190
191
  const delivery = page(peterRoute.id);
191
192
  const report = await pollInbound(db, deps([inbound(WIFE, delivery.token)]));
192
193
 
193
- expect(report.rejected).toBe(1);
194
+ expect(report.unheard).toEqual([{ senderAddress: WIFE, reason: 'wrong_sender' }]);
194
195
  expect(report.acked).toBe(0);
195
196
  expect(alertState()).toBe('firing');
196
197
  });
@@ -200,7 +201,7 @@ describe('pollInbound', () => {
200
201
  const delivery = page(peterRoute.id);
201
202
  const report = await pollInbound(db, deps([inbound(STRANGER, delivery.token)]));
202
203
 
203
- expect(report.ignored).toBe(1);
204
+ expect(report.unheard).toEqual([{ senderAddress: STRANGER, reason: 'unknown_sender' }]);
204
205
  expect(alertState()).toBe('firing');
205
206
  });
206
207
 
@@ -228,7 +229,7 @@ describe('pollInbound', () => {
228
229
  expect(first.acked).toBe(1);
229
230
  // The token is spent, so the replay is refused rather than acking again.
230
231
  expect(second.acked).toBe(0);
231
- expect(second.rejected).toBe(1);
232
+ expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unknown_token' }]);
232
233
  expect(alertState()).toBe('acked');
233
234
  });
234
235
 
@@ -262,37 +263,263 @@ describe('pollInbound', () => {
262
263
  });
263
264
 
264
265
  const report = await pollInbound(db, deps([inbound(PETER, 'ack')]));
265
- expect(report.rejected).toBe(1);
266
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'ambiguous' }]);
266
267
  expect(alertState()).toBe('firing');
267
268
  });
268
269
 
269
270
  // One dead transport must not stop the others being read.
270
271
  test('an unreachable transport is skipped without throwing', async () => {
271
- const report = await pollInbound(db, deps([], { receiveFrom: async () => null }));
272
+ const report = await pollInbound(
273
+ db,
274
+ deps([], { receiveFrom: async () => ({ status: 'failed', error: 'connection refused' }) }),
275
+ );
272
276
  expect(report.transportsPolled).toBe(1);
273
277
  expect(report.messagesRead).toBe(0);
274
278
  });
275
279
 
280
+ /**
281
+ * The regression that cost a week. signal-cli's daemon refused celilo's read
282
+ * call outright ("Receive command cannot be used if messages are already
283
+ * being received") and the poller reported the same "0 message(s)" it
284
+ * reports when nobody has replied. The reason must reach the report, or the
285
+ * two are indistinguishable from outside.
286
+ */
287
+ test('a transport that REFUSES the read says so instead of looking quiet', async () => {
288
+ const refusal = 'Receive command cannot be used if messages are already being received.';
289
+ const report = await pollInbound(
290
+ db,
291
+ deps([], { receiveFrom: async () => ({ status: 'failed', error: refusal }) }),
292
+ );
293
+
294
+ expect(report.failures).toEqual([{ transportModuleId: 'signal', error: refusal }]);
295
+ // ...and a genuinely quiet transport must NOT look like a failure.
296
+ const quiet = await pollInbound(db, deps([]));
297
+ expect(quiet.failures).toEqual([]);
298
+ expect(quiet.messagesRead).toBe(0);
299
+ });
300
+
301
+ test('a unidirectional transport is not reported as a failure', async () => {
302
+ const report = await pollInbound(
303
+ db,
304
+ deps([], { receiveFrom: async () => ({ status: 'unidirectional' }) }),
305
+ );
306
+ expect(report.failures).toEqual([]);
307
+ expect(report.transportsPolled).toBe(1);
308
+ });
309
+
276
310
  test('the cursor is persisted after a successful poll', async () => {
277
311
  await pollInbound(db, deps([]));
278
312
  expect(cursors.get('signal')).toBe('cursor-1');
279
313
  });
280
314
 
281
315
  test('the cursor is NOT advanced when the transport could not be reached', async () => {
282
- await pollInbound(db, deps([], { receiveFrom: async () => null }));
316
+ await pollInbound(
317
+ db,
318
+ deps([], { receiveFrom: async () => ({ status: 'failed', error: 'connection refused' }) }),
319
+ );
283
320
  expect(cursors.get('signal')).toBeUndefined();
284
321
  });
285
322
 
286
323
  test('gibberish is rejected without touching the alert', async () => {
287
324
  page(peterRoute.id);
288
325
  const report = await pollInbound(db, deps([inbound(PETER, 'what is going on')]));
289
- expect(report.rejected).toBe(1);
326
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
290
327
  expect(alertState()).toBe('firing');
291
328
  });
292
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
+
293
389
  test('a route pointing at a transport nobody uses is not polled', () => {
294
390
  db.delete(alerts).run();
295
391
  expect(transportsWithRoutes(db)).toEqual(['signal']);
296
392
  expect(wifeRoute.transportModuleId).toBe('signal');
297
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
+ });
298
525
  });