@celilo/cli 0.16.1 → 0.17.0

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/src/cli/index.ts CHANGED
@@ -839,6 +839,9 @@ Subcommands:
839
839
  list List all configured storage destinations
840
840
  verify <storage-id> Test storage connectivity and permissions
841
841
  set-default <id> Set the default backup storage destination
842
+ set-path <id> <path> Relocate a local destination to a new directory
843
+ Options:
844
+ --no-migrate Change the path without moving existing archives
842
845
  remove <storage-id> Remove a storage destination
843
846
  Options:
844
847
  --force Skip confirmation prompts
@@ -860,6 +863,10 @@ Examples:
860
863
  # Set default destination
861
864
  celilo storage set-default local-backups
862
865
 
866
+ # Move a local destination (archives at the old path are moved too;
867
+ # if that path is gone, the change proceeds with nothing to migrate)
868
+ celilo storage set-path local-backups /var/lib/celilo/backups
869
+
863
870
  # Remove storage
864
871
  celilo storage remove local-backups --force
865
872
 
@@ -1836,6 +1843,11 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1836
1843
  return handleStorageSetDefault(parsed.args, parsed.flags);
1837
1844
  }
1838
1845
 
1846
+ if (parsed.subcommand === 'set-path') {
1847
+ const { handleStorageSetPath } = await import('./commands/storage-set-path');
1848
+ return handleStorageSetPath(parsed.args, parsed.flags);
1849
+ }
1850
+
1839
1851
  return {
1840
1852
  success: false,
1841
1853
  error: `Unknown storage subcommand: ${parsed.subcommand}\n\nRun "celilo storage --help" for usage`,
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import type { DriftFinding, SystemAuditReport } from '../../services/audit/types';
2
+ import type { DriftCategory, DriftFinding, SystemAuditReport } from '../../services/audit/types';
3
3
  import {
4
+ ALL_CATEGORIES,
4
5
  type AuditTuiState,
6
+ CATEGORY_LABELS,
5
7
  groupFindings,
6
8
  initState,
7
9
  reducer,
@@ -244,3 +246,15 @@ describe('selectedFinding', () => {
244
246
  expect(selectedFinding(initState(report([])))).toBeNull();
245
247
  });
246
248
  });
249
+
250
+ // ALL_CATEGORIES is a plain array, so the type system cannot require every
251
+ // DriftCategory to appear in it — a new category compiles fine and then never
252
+ // shows up in the TUI. CATEGORY_LABELS is a Record and IS exhaustive, so it is
253
+ // the honest source of truth to compare against.
254
+ describe('ALL_CATEGORIES covers every category', () => {
255
+ test('every labelled category is listed, and vice versa', () => {
256
+ expect([...ALL_CATEGORIES].sort()).toEqual(
257
+ (Object.keys(CATEGORY_LABELS) as DriftCategory[]).sort(),
258
+ );
259
+ });
260
+ });
@@ -83,6 +83,7 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
83
83
  'secrets_decryptable',
84
84
  'services_reachable',
85
85
  'machines_reachable',
86
+ 'transport_reads',
86
87
  'trusted_sources',
87
88
  ];
88
89
 
@@ -101,6 +102,7 @@ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
101
102
  secrets_decryptable: 'Secrets',
102
103
  services_reachable: 'Service reachability',
103
104
  machines_reachable: 'Machine reachability',
105
+ transport_reads: 'Transport readability',
104
106
  trusted_sources: 'Trusted networks',
105
107
  };
106
108
 
@@ -1,5 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo } from './release-metadata';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ type GitCommandRunner,
8
+ buildReleaseMetadata,
9
+ collectGitInfo,
10
+ makeRealGitRunner,
11
+ } from './release-metadata';
3
12
 
4
13
  describe('buildReleaseMetadata', () => {
5
14
  test('produces a stable shape from injected inputs', () => {
@@ -77,7 +86,7 @@ describe('collectGitInfo', () => {
77
86
  expect(calls).toEqual([
78
87
  ['rev-parse', 'HEAD'],
79
88
  ['rev-parse', '--abbrev-ref', 'HEAD'],
80
- ['status', '--porcelain'],
89
+ ['status', '--porcelain', '--', '.'],
81
90
  ]);
82
91
  });
83
92
 
@@ -101,3 +110,68 @@ describe('collectGitInfo', () => {
101
110
  expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
102
111
  });
103
112
  });
113
+
114
+ /**
115
+ * Recurrence gate for #544, against a REAL git repo rather than a fake runner.
116
+ *
117
+ * The tests above pin the argv `collectGitInfo` constructs. That is what let the
118
+ * bug live: they asserted which command was built, never what it concluded, so
119
+ * they stayed green while the function reported the whole repository's dirt for
120
+ * every module. A fake `GitCommandRunner` cannot catch this at all — the defect
121
+ * IS git's real scoping behavior, which a stub by definition does not model.
122
+ *
123
+ * So this drives the real `makeRealGitRunner()` against a real checkout. Delete
124
+ * the `-- .` pathspec and the first test here fails; that is the whole point.
125
+ */
126
+ describe('collectGitInfo scopes dirt to sourceDir (#544, real git)', () => {
127
+ let repo: string;
128
+ let moduleDir: string;
129
+ const git = (args: string[], cwd: string) =>
130
+ execFileSync('git', args, { cwd, encoding: 'utf-8' });
131
+
132
+ beforeEach(() => {
133
+ repo = mkdtempSync(join(tmpdir(), 'celilo-gitinfo-'));
134
+ moduleDir = join(repo, 'modules', 'probe');
135
+ mkdirSync(moduleDir, { recursive: true });
136
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\n');
137
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\n');
138
+
139
+ git(['init', '-q'], repo);
140
+ git(['config', 'user.email', 'test@celilo.test'], repo);
141
+ git(['config', 'user.name', 'Test'], repo);
142
+ git(['add', '-A'], repo);
143
+ git(['commit', '-qm', 'init'], repo);
144
+ });
145
+
146
+ afterEach(() => rmSync(repo, { recursive: true, force: true }));
147
+
148
+ test('a tracked file rewritten OUTSIDE the module does not make it dirty', () => {
149
+ // Exactly what broke the release: `bun install` rewrites the tracked root
150
+ // `bun.lock` between module publishes. The module itself is untouched.
151
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\nrewritten by bun install\n');
152
+
153
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
154
+ });
155
+
156
+ test("a sibling module's dirt does not make this module dirty", () => {
157
+ const sibling = join(repo, 'modules', 'other');
158
+ mkdirSync(sibling, { recursive: true });
159
+ writeFileSync(join(sibling, 'manifest.yml'), 'id: other\n');
160
+
161
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
162
+ });
163
+
164
+ test("the module's OWN dirt still blocks the publish", () => {
165
+ // The control. Scoping must not have simply disabled the check — this is
166
+ // the case the guard exists for, and it must still fire.
167
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\nversion: 9.9.9\n');
168
+
169
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
170
+ });
171
+
172
+ test('an untracked file inside the module still blocks the publish', () => {
173
+ writeFileSync(join(moduleDir, 'stray.txt'), 'oops\n');
174
+
175
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
176
+ });
177
+ });
@@ -129,7 +129,17 @@ export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInf
129
129
  // `git status --porcelain` prints one line per modified/untracked file.
130
130
  // Empty output = clean. Null (command failure) is treated as not-dirty
131
131
  // because we don't want to falsely block a publish.
132
- const status = run(['status', '--porcelain'], sourceDir);
132
+ //
133
+ // The `-- .` pathspec is load-bearing (#544). `git status` reports the WHOLE
134
+ // repository regardless of cwd, so passing `sourceDir` as cwd scoped nothing:
135
+ // every caller asks about one module, and got back the dirt of all of them
136
+ // plus the repo root. The release pipeline runs `bun install` between module
137
+ // publishes, which rewrites the tracked `bun.lock` at the root — so modules
138
+ // 1..N published fine and the next one failed with "Working tree at
139
+ // modules/<clean-module> has uncommitted changes", naming a directory that
140
+ // was clean and sending you to inspect it. Order-dependent, so it looked
141
+ // like a random module failing.
142
+ const status = run(['status', '--porcelain', '--', '.'], sourceDir);
133
143
  const dirty = status !== null && status.length > 0;
134
144
 
135
145
  return {
@@ -24,6 +24,7 @@ import { type FailingKey, builtinAlertKey } from './keys';
24
24
  */
25
25
  const TARGET_KIND_BY_CATEGORY: Partial<Record<DriftCategory, string>> = {
26
26
  machines_reachable: 'machine',
27
+ transport_reads: 'module',
27
28
  services_reachable: 'service',
28
29
  services_credentials: 'service',
29
30
  backups: 'module',
@@ -7,7 +7,12 @@ import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../../db/client';
8
8
  import { type Route, alerts, modules, monitors, notificationDeliveries } from '../../db/schema';
9
9
  import { setupTestDatabase } from '../../test-utils/setup-test-db';
10
- import { type InboundPollDeps, pollInbound, transportsWithRoutes } from './inbound-poller';
10
+ import {
11
+ type InboundPollDeps,
12
+ type TransportReadRecord,
13
+ pollInbound,
14
+ transportsWithRoutes,
15
+ } from './inbound-poller';
11
16
  import { moduleCheckAlertKey } from './keys';
12
17
  import { createPerson, createRoute } from './people';
13
18
  import { mintDelivery } from './tokens';
@@ -386,6 +391,63 @@ describe('pollInbound', () => {
386
391
  expect(ackedBy()).toBe(peterRoute.personId);
387
392
  });
388
393
 
394
+ // The gap this closes. The ONLY per-transport state celilo persisted was the
395
+ // cursor, and writeCursor early-returns when there is no cursor — which a
396
+ // failed read never produces. So the store could not REPRESENT a failure, and
397
+ // "unreadable for a week" and "nobody replied for a week" left identical
398
+ // traces (#501).
399
+ describe('recording what each read attempt produced', () => {
400
+ const records: Array<[string, TransportReadRecord]> = [];
401
+ const recording = (over: Partial<InboundPollDeps> = {}) =>
402
+ deps([], { recordRead: (t, r) => records.push([t, r]), ...over });
403
+
404
+ beforeEach(() => {
405
+ records.length = 0;
406
+ });
407
+
408
+ test('a FAILED read is recorded — the case the cursor could never express', async () => {
409
+ await pollInbound(
410
+ db,
411
+ recording({ receiveFrom: async () => ({ status: 'failed', error: 'connection refused' }) }),
412
+ );
413
+ expect(records).toHaveLength(1);
414
+ expect(records[0][0]).toBe('signal');
415
+ expect(records[0][1]).toMatchObject({ outcome: 'failed', error: 'connection refused' });
416
+ });
417
+
418
+ test('a successful read is recorded with how many messages it returned', async () => {
419
+ await pollInbound(
420
+ db,
421
+ deps([inbound(PETER, 'hello')], { recordRead: (t, r) => records.push([t, r]) }),
422
+ );
423
+ expect(records[0][1]).toMatchObject({ outcome: 'received', messages: 1 });
424
+ });
425
+
426
+ // Zero messages is not failure. Conflating them is the original bug.
427
+ test('an empty but successful read records received, not failed', async () => {
428
+ await pollInbound(db, recording());
429
+ expect(records[0][1]).toMatchObject({ outcome: 'received', messages: 0 });
430
+ });
431
+
432
+ test('a unidirectional transport is recorded as such', async () => {
433
+ await pollInbound(db, recording({ receiveFrom: async () => ({ status: 'unidirectional' }) }));
434
+ expect(records[0][1].outcome).toBe('unidirectional');
435
+ });
436
+
437
+ // Every attempt, not just interesting ones — a gap in the record would be
438
+ // read as "nothing happened".
439
+ test('every attempt is recorded, and carries when it happened', async () => {
440
+ await pollInbound(db, recording());
441
+ expect(records[0][1].at).toBe(NOW.toISOString());
442
+ });
443
+
444
+ // Optional dep: a caller that does not persist must still poll.
445
+ test('polling works with no recorder attached', async () => {
446
+ const report = await pollInbound(db, deps([]));
447
+ expect(report.transportsPolled).toBe(1);
448
+ });
449
+ });
450
+
389
451
  test('a route pointing at a transport nobody uses is not polled', () => {
390
452
  db.delete(alerts).run();
391
453
  expect(transportsWithRoutes(db)).toEqual(['signal']);
@@ -44,6 +44,35 @@ export interface TransportFailure {
44
44
  error: string;
45
45
  }
46
46
 
47
+ /**
48
+ * The outcome of one attempt to read a transport, as recorded for later.
49
+ *
50
+ * Written on EVERY attempt, including failures — which is the whole point. The
51
+ * only per-transport state celilo persisted before this was the cursor, and a
52
+ * failed read produces no cursor, so `writeCursor` returned early and nothing
53
+ * was written. The store could not REPRESENT a failure, so an absence of
54
+ * recorded failures was never evidence there had been none: a transport that
55
+ * had not been readable for a week looked identical to one nobody had replied
56
+ * on (#501).
57
+ */
58
+ export interface TransportReadRecord {
59
+ /** When the attempt happened, ISO-8601. */
60
+ at: string;
61
+ outcome: 'received' | 'unidirectional' | 'failed';
62
+ /** Present only when `failed`. */
63
+ error?: string;
64
+ /** How many messages the read returned. Zero is not the same as failure. */
65
+ messages: number;
66
+ /**
67
+ * When a read last SUCCEEDED, carried forward across failures.
68
+ *
69
+ * This is the field that answers the question the old state could not: a
70
+ * transport reporting "0 messages" for a week and one that has not been
71
+ * readable for a week are the same picture until you can see this.
72
+ */
73
+ lastSuccessAt?: string;
74
+ }
75
+
47
76
  /** A message that was read but not acted on, and why. */
48
77
  export interface UnheardMessage {
49
78
  senderAddress: string;
@@ -65,6 +94,13 @@ export interface InboundPollDeps {
65
94
  /** Persisted receive cursor per transport. */
66
95
  readCursor(transportModuleId: string): string | null;
67
96
  writeCursor(transportModuleId: string, cursor: string | null): void;
97
+ /**
98
+ * Record what one read attempt produced. Called for EVERY attempt — a failed
99
+ * read must leave a trace, or "we could not read this transport" stays
100
+ * indistinguishable from "nobody replied". Optional so a caller that does not
101
+ * care (tests, one-off invocations) need not supply it.
102
+ */
103
+ recordRead?(transportModuleId: string, record: TransportReadRecord): void;
68
104
  now(): Date;
69
105
  /**
70
106
  * Transport for a route, so an ack can be broadcast to everyone else paged.
@@ -131,6 +167,12 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
131
167
  for (const transportId of transportsWithRoutes(db)) {
132
168
  const received = await deps.receiveFrom(transportId, deps.readCursor(transportId));
133
169
  report.transportsPolled++;
170
+ deps.recordRead?.(transportId, {
171
+ at: deps.now().toISOString(),
172
+ outcome: received.status,
173
+ ...(received.status === 'failed' ? { error: received.error } : {}),
174
+ messages: received.status === 'received' ? received.messages.length : 0,
175
+ });
134
176
  // One dead transport must not stop the others being read — but it is
135
177
  // RECORDED rather than skipped in silence, because "cannot read" and
136
178
  // "nothing to read" are the two things an operator most needs to tell
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The per-transport record of whether celilo can still READ replies.
3
+ *
4
+ * Extracted from the poll command because it now has two readers: the poller
5
+ * writes it, and the audit asserts on it. Keeping the key format private to the
6
+ * writer would have meant the audit re-deriving a string literal — the sort of
7
+ * duplication that silently stops matching.
8
+ *
9
+ * The shape it stores is the point. celilo's only per-transport state used to
10
+ * be the receive cursor, and `writeCursor` returns early when there is no
11
+ * cursor — which a failed read never produces. So the store could not
12
+ * REPRESENT a failure, and "unreadable since Tuesday" left exactly the same
13
+ * trace as "nobody replied since Tuesday" (#501).
14
+ */
15
+
16
+ import { eq } from 'drizzle-orm';
17
+ import type { DbClient } from '../../db/client';
18
+ import { systemConfig } from '../../db/schema';
19
+ import { type TransportReadRecord, transportsWithRoutes } from './inbound-poller';
20
+
21
+ const READ_PREFIX = 'alerting.last_read.';
22
+
23
+ export function readLastRead(db: DbClient, transportModuleId: string): TransportReadRecord | null {
24
+ const row = db
25
+ .select()
26
+ .from(systemConfig)
27
+ .where(eq(systemConfig.key, `${READ_PREFIX}${transportModuleId}`))
28
+ .get();
29
+ if (!row?.value) return null;
30
+ try {
31
+ return JSON.parse(row.value) as TransportReadRecord;
32
+ } catch {
33
+ // A malformed row must not read as "never succeeded" — that would invent a
34
+ // fact and page someone about it.
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Record one attempt, carrying the last SUCCESS forward.
41
+ *
42
+ * Deliberately has no counterpart to `writeCursor`'s early return: a failure
43
+ * that writes nothing is why this record did not exist before. Carrying the
44
+ * success forward is what turns a log into an answer — a run of failures must
45
+ * not erase when the transport last actually worked.
46
+ */
47
+ export function writeLastRead(
48
+ db: DbClient,
49
+ transportModuleId: string,
50
+ record: TransportReadRecord,
51
+ ): void {
52
+ const key = `${READ_PREFIX}${transportModuleId}`;
53
+ const previous = readLastRead(db, transportModuleId);
54
+ const lastSuccessAt =
55
+ record.outcome === 'received' ? record.at : (previous?.lastSuccessAt ?? undefined);
56
+ const value = JSON.stringify({ ...record, ...(lastSuccessAt ? { lastSuccessAt } : {}) });
57
+
58
+ const existing = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
59
+ if (existing) {
60
+ db.update(systemConfig).set({ value }).where(eq(systemConfig.key, key)).run();
61
+ } else {
62
+ db.insert(systemConfig)
63
+ .values({ key, value, description: `Last inbound read attempt for ${transportModuleId}` })
64
+ .run();
65
+ }
66
+ }
67
+
68
+ export interface TransportReadStatus {
69
+ transportModuleId: string;
70
+ last: TransportReadRecord | null;
71
+ }
72
+
73
+ /**
74
+ * Read state for every transport that has a route pointing at it.
75
+ *
76
+ * Scoped to transports with routes on purpose: a transport nobody is routed to
77
+ * cannot fail to deliver anyone's acknowledgement, and paging about it would be
78
+ * noise that trains an operator to ignore this check.
79
+ */
80
+ export function readAllTransportStatuses(db: DbClient): TransportReadStatus[] {
81
+ return transportsWithRoutes(db).map((transportModuleId) => ({
82
+ transportModuleId,
83
+ last: readLastRead(db, transportModuleId),
84
+ }));
85
+ }
@@ -32,6 +32,7 @@ const emptyDeps = {
32
32
  secretsDecryptable: { results: [] },
33
33
  servicesReachable: { results: [] },
34
34
  machinesReachable: { results: [] },
35
+ transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
35
36
  trustedSources: { firewalls: [] },
36
37
  };
37
38
 
@@ -25,6 +25,7 @@ import {
25
25
  } from './services-credentials';
26
26
  import { type ServicesReachableAuditDeps, auditServicesReachable } from './services-reachable';
27
27
  import { type TerraformPlanAuditDeps, auditTerraformPlan } from './terraform-plan';
28
+ import { type TransportReadsAuditDeps, auditTransportReads } from './transport-reads';
28
29
  import { type TrustedSourcesAuditDeps, auditTrustedSources } from './trusted-sources';
29
30
  import {
30
31
  type DriftCategory,
@@ -53,6 +54,7 @@ export interface AuditDeps {
53
54
  secretsDecryptable: SecretsDecryptableAuditDeps;
54
55
  servicesReachable: ServicesReachableAuditDeps;
55
56
  machinesReachable: MachinesReachableAuditDeps;
57
+ transportReads: TransportReadsAuditDeps;
56
58
  trustedSources: TrustedSourcesAuditDeps;
57
59
  /** Defaults to `Date.now()`-based ISO string. */
58
60
  now?: () => Date;
@@ -103,6 +105,7 @@ export async function runAudit(
103
105
  wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
104
106
  wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
105
107
  wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
108
+ wrap('transport_reads', auditTransportReads(deps.transportReads)),
106
109
  wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
107
110
  ]);
108
111
 
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { TransportReadStatus } from '../alerting/read-records';
3
+ import { auditTransportReads } from './transport-reads';
4
+
5
+ const NOW = new Date('2026-08-01T12:00:00Z');
6
+ const STALE_AFTER = 30 * 60_000;
7
+
8
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
9
+
10
+ function status(over: Partial<TransportReadStatus['last']> | null): TransportReadStatus {
11
+ return {
12
+ transportModuleId: 'signal',
13
+ last:
14
+ over === null
15
+ ? null
16
+ : { at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0), ...over },
17
+ };
18
+ }
19
+
20
+ const run = (statuses: TransportReadStatus[]) =>
21
+ auditTransportReads({ statuses, now: NOW, staleAfterMs: STALE_AFTER });
22
+
23
+ describe('auditTransportReads', () => {
24
+ test('a transport read successfully just now is not a finding', async () => {
25
+ expect(await run([status({})])).toEqual([]);
26
+ });
27
+
28
+ // THE case this check exists for. Six tokens were issued and zero consumed
29
+ // over a week, and nothing anywhere was red (#501).
30
+ test('a transport not read successfully for hours is drift', async () => {
31
+ const findings = await run([
32
+ status({
33
+ at: ago(60_000),
34
+ outcome: 'failed',
35
+ error: 'refused',
36
+ lastSuccessAt: ago(3 * 3600_000),
37
+ }),
38
+ ]);
39
+ expect(findings).toHaveLength(1);
40
+ expect(findings[0]).toMatchObject({
41
+ category: 'transport_reads',
42
+ code: 'transport_reads_stale',
43
+ severity: 'drift',
44
+ subject: 'signal',
45
+ });
46
+ expect(findings[0].message).toContain('3h ago');
47
+ });
48
+
49
+ // The decision that makes staleness safe to page on: an EMPTY read is a
50
+ // success, so a quiet transport keeps refreshing lastSuccessAt. If empty
51
+ // reads counted as failure this check would page on every quiet afternoon
52
+ // and be switched off within a week.
53
+ test('a quiet transport — successful reads, zero messages — is NOT stale', async () => {
54
+ const findings = await run([
55
+ status({ at: ago(0), outcome: 'received', messages: 0, lastSuccessAt: ago(0) }),
56
+ ]);
57
+ expect(findings).toEqual([]);
58
+ });
59
+
60
+ test('a transport that has never succeeded is drift, even if attempts are recent', async () => {
61
+ const findings = await run([
62
+ status({
63
+ at: ago(0),
64
+ outcome: 'failed',
65
+ error: 'connection refused',
66
+ lastSuccessAt: undefined,
67
+ }),
68
+ ]);
69
+ expect(findings[0]).toMatchObject({ code: 'transport_never_read', severity: 'drift' });
70
+ expect(findings[0].details).toContain('connection refused');
71
+ });
72
+
73
+ test('a transport with no record at all is drift, not silently fine', async () => {
74
+ const findings = await run([status(null)]);
75
+ expect(findings[0]).toMatchObject({ code: 'transport_never_polled', severity: 'drift' });
76
+ });
77
+
78
+ // A transport with no `receive` is working as designed. Paging about it
79
+ // would be paging about a healthy system, which teaches operators to ignore
80
+ // the check.
81
+ test('a unidirectional transport is never a finding', async () => {
82
+ const findings = await run([
83
+ status({ at: ago(10 * 3600_000), outcome: 'unidirectional', lastSuccessAt: undefined }),
84
+ ]);
85
+ expect(findings).toEqual([]);
86
+ });
87
+
88
+ test('just inside the threshold is not yet drift', async () => {
89
+ expect(await run([status({ lastSuccessAt: ago(STALE_AFTER - 1_000) })])).toEqual([]);
90
+ });
91
+
92
+ test('just outside the threshold is drift', async () => {
93
+ const findings = await run([status({ lastSuccessAt: ago(STALE_AFTER + 1_000) })]);
94
+ expect(findings).toHaveLength(1);
95
+ });
96
+
97
+ // The remediation must point at something that does not consume the queue.
98
+ // `celilo alerts poll` would READ, and a suggestion that eats the operator's
99
+ // acknowledgement is worse than no suggestion (#541).
100
+ test('a stale finding sends you to the journal, not to a read', async () => {
101
+ const findings = await run([status({ lastSuccessAt: ago(3 * 3600_000) })]);
102
+ expect(findings[0].remediation).toBe('celilo module journal signal');
103
+ });
104
+
105
+ test('each transport is judged on its own record', async () => {
106
+ const findings = await run([
107
+ { transportModuleId: 'signal', last: status({}).last },
108
+ { transportModuleId: 'sms', last: status({ lastSuccessAt: ago(9 * 3600_000) }).last },
109
+ ]);
110
+ expect(findings).toHaveLength(1);
111
+ expect(findings[0].subject).toBe('sms');
112
+ });
113
+ });