@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +19 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +3 -30
- package/src/cli/completion.ts +20 -1
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +14 -0
- package/src/db/schema.ts +5 -3
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +17 -2
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +235 -8
- package/src/services/alerting/inbound-poller.ts +95 -34
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- 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 {
|
|
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 =
|
|
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
|
package/src/cli/completion.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
1052
|
-
|
|
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' }),
|
package/src/manifest/schema.ts
CHANGED
|
@@ -694,7 +694,10 @@ export const ModuleManifestSchema = z
|
|
|
694
694
|
|
|
695
695
|
backup: z
|
|
696
696
|
.object({
|
|
697
|
-
|
|
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[] = [
|
|
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(
|
|
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,
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
});
|