@remit/doctor 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +29 -0
- package/src/attempt.ts +34 -0
- package/src/check.test.ts +81 -0
- package/src/check.ts +36 -0
- package/src/cli.ts +43 -0
- package/src/config.test.ts +115 -0
- package/src/config.ts +241 -0
- package/src/deadman.test.ts +31 -0
- package/src/deadman.ts +34 -0
- package/src/dwell.test.ts +120 -0
- package/src/dwell.ts +53 -0
- package/src/heartbeats.test.ts +68 -0
- package/src/heartbeats.ts +68 -0
- package/src/index.ts +18 -0
- package/src/log.ts +71 -0
- package/src/loop.test.ts +301 -0
- package/src/loop.ts +154 -0
- package/src/main.ts +69 -0
- package/src/prometheus.test.ts +105 -0
- package/src/prometheus.ts +136 -0
- package/src/report.test.ts +165 -0
- package/src/report.ts +106 -0
- package/src/scrape.test.ts +71 -0
- package/src/scrape.ts +63 -0
- package/src/state.test.ts +111 -0
- package/src/state.ts +127 -0
- package/src/verdict.test.ts +554 -0
- package/src/verdict.ts +386 -0
- package/src/webhook.test.ts +241 -0
- package/src/webhook.ts +155 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { HeartbeatReading } from "./heartbeats.js";
|
|
4
|
+
import { parseMetrics } from "./prometheus.js";
|
|
5
|
+
import type { ScrapeResult } from "./scrape.js";
|
|
6
|
+
import type { CounterState } from "./state.js";
|
|
7
|
+
import { evaluate, formatDuration, type VerdictInput } from "./verdict.js";
|
|
8
|
+
|
|
9
|
+
const scrape = (service: string, body: string): ScrapeResult => ({
|
|
10
|
+
service,
|
|
11
|
+
samples: parseMetrics(body),
|
|
12
|
+
error: undefined,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const failed = (service: string, error: string): ScrapeResult => ({
|
|
16
|
+
service,
|
|
17
|
+
samples: [],
|
|
18
|
+
error,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const fresh = (service: string): HeartbeatReading => ({
|
|
22
|
+
service,
|
|
23
|
+
ageSeconds: 12,
|
|
24
|
+
error: undefined,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const HEALTHY_SCRAPES: ScrapeResult[] = [
|
|
28
|
+
scrape("backend", 'remit_account_sync_age_seconds{account_id="a"} 90\n'),
|
|
29
|
+
scrape(
|
|
30
|
+
"queue",
|
|
31
|
+
'remit_queue_messages{queue="imap-sync",role="work"} 2\nremit_queue_messages{queue="imap-sync-dlq",role="dead_letter"} 0\n',
|
|
32
|
+
),
|
|
33
|
+
scrape(
|
|
34
|
+
"imap-worker",
|
|
35
|
+
'remit_imap_failures_total{operation="fetch",kind="auth"} 0\n',
|
|
36
|
+
),
|
|
37
|
+
scrape("smtp-worker", 'remit_smtp_failures_total{kind="auth"} 0\n'),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const HEALTHY_HEARTBEATS = [
|
|
41
|
+
fresh("imap-worker"),
|
|
42
|
+
fresh("smtp-worker"),
|
|
43
|
+
fresh("account-worker"),
|
|
44
|
+
fresh("search-index-worker"),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const NOW = new Date("2026-07-27T10:00:00.000Z");
|
|
48
|
+
|
|
49
|
+
/** A counter the checker has already seen, last risen long enough ago to be quiet. */
|
|
50
|
+
const counter = (total: number, roseMsAgo = 4 * 60 * 60 * 1000) => ({
|
|
51
|
+
total,
|
|
52
|
+
lastRoseAt: NOW.getTime() - roseMsAgo,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const input = (overrides: Partial<VerdictInput> = {}): VerdictInput => ({
|
|
56
|
+
scrapes: HEALTHY_SCRAPES,
|
|
57
|
+
heartbeats: HEALTHY_HEARTBEATS,
|
|
58
|
+
previousCounters: {},
|
|
59
|
+
heartbeatMaxAgeSeconds: 420,
|
|
60
|
+
syncAgeMaxSeconds: 10_800,
|
|
61
|
+
authFailureHoldSeconds: 10_800,
|
|
62
|
+
now: NOW,
|
|
63
|
+
...overrides,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("evaluate", () => {
|
|
67
|
+
it("is healthy when every signal reads clean", () => {
|
|
68
|
+
const result = evaluate(input());
|
|
69
|
+
assert.equal(result.verdict, "healthy");
|
|
70
|
+
assert.deepEqual(result.reasons, []);
|
|
71
|
+
assert.equal(result.summary, "remit is healthy");
|
|
72
|
+
assert.equal(result.checkedAt, "2026-07-27T10:00:00.000Z");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("degrades on a target that did not answer, and names the services", () => {
|
|
76
|
+
const result = evaluate(
|
|
77
|
+
input({
|
|
78
|
+
scrapes: [
|
|
79
|
+
failed("backend", "fetch failed"),
|
|
80
|
+
...HEALTHY_SCRAPES.slice(1),
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
assert.equal(result.verdict, "degraded");
|
|
85
|
+
assert.equal(result.reasons[0].code, "scrape_failed");
|
|
86
|
+
assert.match(result.reasons[0].summary, /1 of 4 services is not answering/);
|
|
87
|
+
assert.match(result.reasons[0].summary, /backend/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("degrades on a stale heartbeat", () => {
|
|
91
|
+
const result = evaluate(
|
|
92
|
+
input({
|
|
93
|
+
heartbeats: [
|
|
94
|
+
{ service: "imap-worker", ageSeconds: 900, error: undefined },
|
|
95
|
+
...HEALTHY_HEARTBEATS.slice(1),
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
assert.equal(result.reasons[0].code, "worker_heartbeat_stale");
|
|
100
|
+
assert.match(result.reasons[0].summary, /imap-worker/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("degrades when a worker has no heartbeat file at all, never healthy", () => {
|
|
104
|
+
const result = evaluate(
|
|
105
|
+
input({
|
|
106
|
+
heartbeats: [
|
|
107
|
+
{
|
|
108
|
+
service: "smtp-worker",
|
|
109
|
+
ageSeconds: undefined,
|
|
110
|
+
error: "no heartbeat file",
|
|
111
|
+
},
|
|
112
|
+
...HEALTHY_HEARTBEATS.slice(1),
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(result.verdict, "degraded");
|
|
117
|
+
assert.equal(result.reasons[0].code, "worker_heartbeat_stale");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("degrades on a non-empty dead-letter queue and names the queue", () => {
|
|
121
|
+
const result = evaluate(
|
|
122
|
+
input({
|
|
123
|
+
scrapes: [
|
|
124
|
+
HEALTHY_SCRAPES[0],
|
|
125
|
+
scrape(
|
|
126
|
+
"queue",
|
|
127
|
+
'remit_queue_messages{queue="imap-sync-dlq",role="dead_letter"} 3\nremit_queue_messages{queue="smtp-send-dlq",role="dead_letter"} 1\n',
|
|
128
|
+
),
|
|
129
|
+
...HEALTHY_SCRAPES.slice(2),
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
const reason = result.reasons.find(
|
|
134
|
+
(candidate) => candidate.code === "dead_letter_queue_not_empty",
|
|
135
|
+
);
|
|
136
|
+
assert.ok(reason);
|
|
137
|
+
assert.match(
|
|
138
|
+
reason.summary,
|
|
139
|
+
/4 messages are quarantined on 2 dead-letter queues/,
|
|
140
|
+
);
|
|
141
|
+
assert.match(reason.summary, /imap-sync-dlq, smtp-send-dlq/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("degrades on a stalled account, in counts, with the ids only in detail", () => {
|
|
145
|
+
const result = evaluate(
|
|
146
|
+
input({
|
|
147
|
+
scrapes: [
|
|
148
|
+
scrape(
|
|
149
|
+
"backend",
|
|
150
|
+
'remit_account_sync_age_seconds{account_id="aaa"} 40000\nremit_account_sync_age_seconds{account_id="bbb"} 90\n',
|
|
151
|
+
),
|
|
152
|
+
...HEALTHY_SCRAPES.slice(1),
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const reason = result.reasons[0];
|
|
157
|
+
assert.equal(reason.code, "account_sync_stalled");
|
|
158
|
+
assert.equal(
|
|
159
|
+
reason.summary,
|
|
160
|
+
"1 of 2 accounts has not completed a sync in over 3h",
|
|
161
|
+
);
|
|
162
|
+
assert.ok(!reason.summary.includes("aaa"));
|
|
163
|
+
assert.match(reason.detail ?? "", /aaa/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("says nothing about authentication on the first check, having no baseline", () => {
|
|
167
|
+
const result = evaluate(
|
|
168
|
+
input({
|
|
169
|
+
scrapes: [
|
|
170
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
171
|
+
scrape(
|
|
172
|
+
"imap-worker",
|
|
173
|
+
'remit_imap_failures_total{operation="connect",kind="auth"} 7\n',
|
|
174
|
+
),
|
|
175
|
+
HEALTHY_SCRAPES[3],
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
assert.equal(result.verdict, "healthy");
|
|
180
|
+
assert.deepEqual(result.counters["imap-worker:imap_auth_failures"], {
|
|
181
|
+
total: 7,
|
|
182
|
+
lastRoseAt: null,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("degrades when the authentication counter rises, not when it merely stands", () => {
|
|
187
|
+
const withFailures = [
|
|
188
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
189
|
+
scrape(
|
|
190
|
+
"imap-worker",
|
|
191
|
+
'remit_imap_failures_total{operation="connect",kind="auth"} 7\n',
|
|
192
|
+
),
|
|
193
|
+
HEALTHY_SCRAPES[3],
|
|
194
|
+
];
|
|
195
|
+
const standing = evaluate(
|
|
196
|
+
input({
|
|
197
|
+
scrapes: withFailures,
|
|
198
|
+
previousCounters: { "imap-worker:imap_auth_failures": counter(7) },
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
assert.equal(standing.verdict, "healthy");
|
|
202
|
+
|
|
203
|
+
const rising = evaluate(
|
|
204
|
+
input({
|
|
205
|
+
scrapes: withFailures,
|
|
206
|
+
previousCounters: { "imap-worker:imap_auth_failures": counter(4) },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
assert.equal(rising.verdict, "degraded");
|
|
210
|
+
assert.match(rising.reasons[0].summary, /IMAP \(last failure 0s ago\)/);
|
|
211
|
+
assert.equal(
|
|
212
|
+
rising.counters["imap-worker:imap_auth_failures"].lastRoseAt,
|
|
213
|
+
NOW.getTime(),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// The bug this replaces: the delta is true for one check, the dwell needs
|
|
218
|
+
// three, and the failures arrive one burst per hourly sync tick — so the one
|
|
219
|
+
// class of failure that never resolves itself never alerted.
|
|
220
|
+
it("stays degraded between the bursts, so the dwell can settle", () => {
|
|
221
|
+
const flat = [
|
|
222
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
223
|
+
scrape(
|
|
224
|
+
"imap-worker",
|
|
225
|
+
'remit_imap_failures_total{operation="connect",kind="auth"} 7\n',
|
|
226
|
+
),
|
|
227
|
+
HEALTHY_SCRAPES[3],
|
|
228
|
+
];
|
|
229
|
+
// The check after the rise sees the same total, an hour before the next
|
|
230
|
+
// tick re-tries the password.
|
|
231
|
+
let carried: Readonly<Record<string, CounterState>> = {
|
|
232
|
+
"imap-worker:imap_auth_failures": counter(4),
|
|
233
|
+
};
|
|
234
|
+
for (let check = 0; check < 4; check += 1) {
|
|
235
|
+
const result = evaluate(
|
|
236
|
+
input({ scrapes: flat, previousCounters: carried, now: NOW }),
|
|
237
|
+
);
|
|
238
|
+
assert.equal(result.verdict, "degraded", `check ${check}`);
|
|
239
|
+
assert.equal(result.reasons[0].code, "mail_auth_failing");
|
|
240
|
+
carried = result.counters;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("clears once the counter has been flat for the hold window", () => {
|
|
245
|
+
const flat = [
|
|
246
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
247
|
+
scrape(
|
|
248
|
+
"imap-worker",
|
|
249
|
+
'remit_imap_failures_total{operation="connect",kind="auth"} 7\n',
|
|
250
|
+
),
|
|
251
|
+
HEALTHY_SCRAPES[3],
|
|
252
|
+
];
|
|
253
|
+
const inside = evaluate(
|
|
254
|
+
input({
|
|
255
|
+
scrapes: flat,
|
|
256
|
+
previousCounters: {
|
|
257
|
+
"imap-worker:imap_auth_failures": {
|
|
258
|
+
total: 7,
|
|
259
|
+
lastRoseAt: NOW.getTime() - 10_000 * 1000,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
assert.equal(inside.verdict, "degraded");
|
|
265
|
+
|
|
266
|
+
const outside = evaluate(
|
|
267
|
+
input({
|
|
268
|
+
scrapes: flat,
|
|
269
|
+
previousCounters: {
|
|
270
|
+
"imap-worker:imap_auth_failures": {
|
|
271
|
+
total: 7,
|
|
272
|
+
lastRoseAt: NOW.getTime() - 11_000 * 1000,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
assert.equal(outside.verdict, "healthy");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("keeps holding the condition open while the exporter is unreachable", () => {
|
|
281
|
+
const result = evaluate(
|
|
282
|
+
input({
|
|
283
|
+
scrapes: [
|
|
284
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
285
|
+
failed("imap-worker", "connect ECONNREFUSED"),
|
|
286
|
+
HEALTHY_SCRAPES[3],
|
|
287
|
+
],
|
|
288
|
+
previousCounters: {
|
|
289
|
+
"imap-worker:imap_auth_failures": {
|
|
290
|
+
total: 7,
|
|
291
|
+
lastRoseAt: NOW.getTime() - 60_000,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
assert.ok(
|
|
297
|
+
result.reasons.some((reason) => reason.code === "mail_auth_failing"),
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("counts the whole total as new when the exporter restarted", () => {
|
|
302
|
+
const result = evaluate(
|
|
303
|
+
input({
|
|
304
|
+
scrapes: [
|
|
305
|
+
...HEALTHY_SCRAPES.slice(0, 3),
|
|
306
|
+
scrape("smtp-worker", 'remit_smtp_failures_total{kind="auth"} 2\n'),
|
|
307
|
+
],
|
|
308
|
+
previousCounters: { "smtp-worker:smtp_auth_failures": counter(50) },
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
assert.match(result.reasons[0].summary, /SMTP \(last failure 0s ago\)/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("ignores failures that are not authentication", () => {
|
|
315
|
+
const result = evaluate(
|
|
316
|
+
input({
|
|
317
|
+
scrapes: [
|
|
318
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
319
|
+
scrape(
|
|
320
|
+
"imap-worker",
|
|
321
|
+
'remit_imap_failures_total{operation="fetch",kind="network"} 99\n',
|
|
322
|
+
),
|
|
323
|
+
HEALTHY_SCRAPES[3],
|
|
324
|
+
],
|
|
325
|
+
previousCounters: { "imap-worker:imap_auth_failures": counter(0) },
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
assert.equal(result.verdict, "healthy");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("carries a counter forward untouched when its exporter did not answer", () => {
|
|
332
|
+
const result = evaluate(
|
|
333
|
+
input({
|
|
334
|
+
scrapes: [
|
|
335
|
+
...HEALTHY_SCRAPES.slice(0, 2),
|
|
336
|
+
failed("imap-worker", "connect ECONNREFUSED"),
|
|
337
|
+
HEALTHY_SCRAPES[3],
|
|
338
|
+
],
|
|
339
|
+
previousCounters: { "imap-worker:imap_auth_failures": counter(12) },
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
// The scrape failure degrades the verdict on its own; what must not happen
|
|
343
|
+
// is the baseline dropping to zero and manufacturing a 12-failure delta
|
|
344
|
+
// the moment the worker comes back.
|
|
345
|
+
assert.equal(result.counters["imap-worker:imap_auth_failures"].total, 12);
|
|
346
|
+
assert.ok(
|
|
347
|
+
!result.reasons.some((reason) => reason.code === "mail_auth_failing"),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("reports every reason at once, in a stable order", () => {
|
|
352
|
+
const result = evaluate(
|
|
353
|
+
input({
|
|
354
|
+
scrapes: [
|
|
355
|
+
scrape(
|
|
356
|
+
"backend",
|
|
357
|
+
'remit_account_sync_age_seconds{account_id="aaa"} 40000\n',
|
|
358
|
+
),
|
|
359
|
+
scrape(
|
|
360
|
+
"queue",
|
|
361
|
+
'remit_queue_messages{queue="dlq",role="dead_letter"} 1\n',
|
|
362
|
+
),
|
|
363
|
+
...HEALTHY_SCRAPES.slice(2),
|
|
364
|
+
],
|
|
365
|
+
heartbeats: [
|
|
366
|
+
{ service: "imap-worker", ageSeconds: 900, error: undefined },
|
|
367
|
+
...HEALTHY_HEARTBEATS.slice(1),
|
|
368
|
+
],
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
assert.deepEqual(
|
|
372
|
+
result.reasons.map((reason) => reason.code),
|
|
373
|
+
[
|
|
374
|
+
"worker_heartbeat_stale",
|
|
375
|
+
"account_sync_stalled",
|
|
376
|
+
"dead_letter_queue_not_empty",
|
|
377
|
+
],
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("degrades when a required series answered but exported nothing", () => {
|
|
382
|
+
const result = evaluate(
|
|
383
|
+
input({
|
|
384
|
+
scrapes: [
|
|
385
|
+
HEALTHY_SCRAPES[0],
|
|
386
|
+
// A 200 with nothing in it: a renamed metric, a collector that
|
|
387
|
+
// started returning [] instead of throwing, a wrong-port target
|
|
388
|
+
// that happens to answer.
|
|
389
|
+
scrape("queue", "# HELP something else\nother_metric 1\n"),
|
|
390
|
+
...HEALTHY_SCRAPES.slice(2),
|
|
391
|
+
],
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
assert.equal(result.verdict, "degraded");
|
|
395
|
+
assert.equal(result.reasons[0].code, "signal_missing");
|
|
396
|
+
assert.match(result.reasons[0].summary, /queue/);
|
|
397
|
+
assert.match(result.reasons[0].detail ?? "", /absent from the response/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("degrades when a required target is not in the target set at all", () => {
|
|
401
|
+
const result = evaluate(
|
|
402
|
+
input({
|
|
403
|
+
// A DOCTOR_TARGETS with no queue endpoint. Nothing errors, nothing is
|
|
404
|
+
// empty — the dead-letter signal is just silently not being read, and
|
|
405
|
+
// reading that as healthy is the headline check failing open.
|
|
406
|
+
scrapes: [HEALTHY_SCRAPES[0], ...HEALTHY_SCRAPES.slice(2)],
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
assert.equal(result.verdict, "degraded");
|
|
410
|
+
assert.equal(result.reasons[0].code, "signal_missing");
|
|
411
|
+
assert.match(result.reasons[0].summary, /queue/);
|
|
412
|
+
assert.match(result.reasons[0].detail ?? "", /no configured target/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("does not require a series from a target that never answered", () => {
|
|
416
|
+
const result = evaluate(
|
|
417
|
+
input({
|
|
418
|
+
scrapes: [
|
|
419
|
+
HEALTHY_SCRAPES[0],
|
|
420
|
+
failed("queue", "connect ECONNREFUSED"),
|
|
421
|
+
...HEALTHY_SCRAPES.slice(2),
|
|
422
|
+
],
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
// scrape_failed already says it; signal_missing would be the same fact twice.
|
|
426
|
+
assert.deepEqual(
|
|
427
|
+
result.reasons.map((reason) => reason.code),
|
|
428
|
+
["scrape_failed"],
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("does not require the series a healthy fresh install legitimately lacks", () => {
|
|
433
|
+
const result = evaluate(
|
|
434
|
+
input({
|
|
435
|
+
scrapes: [
|
|
436
|
+
// No accounts yet, so no sync ages and no auth counters.
|
|
437
|
+
scrape("backend", "# HELP nothing yet\n"),
|
|
438
|
+
HEALTHY_SCRAPES[1],
|
|
439
|
+
scrape("imap-worker", "# HELP nothing yet\n"),
|
|
440
|
+
scrape("smtp-worker", "# HELP nothing yet\n"),
|
|
441
|
+
],
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
assert.equal(result.verdict, "healthy");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("keeps every address, subject and folder name out of every summary", () => {
|
|
448
|
+
const result = evaluate(
|
|
449
|
+
input({
|
|
450
|
+
scrapes: [
|
|
451
|
+
scrape(
|
|
452
|
+
"backend",
|
|
453
|
+
'remit_account_sync_age_seconds{account_id="0f8a-secret"} 40000\n',
|
|
454
|
+
),
|
|
455
|
+
...HEALTHY_SCRAPES.slice(1),
|
|
456
|
+
],
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
for (const reason of result.reasons) {
|
|
460
|
+
assert.ok(!reason.summary.includes("0f8a-secret"));
|
|
461
|
+
assert.ok(!reason.summary.includes("@"));
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// The privacy boundary is meant to be structural, not a habit of the renderer.
|
|
467
|
+
// The likeliest way to break it is not a new placeholder — the webhook tests
|
|
468
|
+
// catch that — but a NEW REASON whose summary interpolates a label it should
|
|
469
|
+
// not. This runs the whole verdict over a scrape where every label D10 forbids
|
|
470
|
+
// carries a sentinel, and fails if any of them reaches a summary.
|
|
471
|
+
//
|
|
472
|
+
// Queue names, service names and operation names are deliberately NOT
|
|
473
|
+
// sentinelled: D10 permits those in a payload, and asserting against them would
|
|
474
|
+
// make the test forbid what the design allows.
|
|
475
|
+
describe("no reason summary may carry a value D10 forbids", () => {
|
|
476
|
+
const SENTINEL = "PII-SENTINEL-b3f1";
|
|
477
|
+
|
|
478
|
+
it("keeps every labelled value out of every summary, whatever the reason", () => {
|
|
479
|
+
const poisoned: ScrapeResult[] = [
|
|
480
|
+
scrape(
|
|
481
|
+
"backend",
|
|
482
|
+
`remit_account_sync_age_seconds{account_id="${SENTINEL}"} 99999\n`,
|
|
483
|
+
),
|
|
484
|
+
scrape(
|
|
485
|
+
"queue",
|
|
486
|
+
'remit_queue_messages{queue="remit-body-dlq",role="dead_letter"} 3\n',
|
|
487
|
+
),
|
|
488
|
+
// `folder` and `mailbox` do not exist on these series today. They are
|
|
489
|
+
// here as the labels a future contributor is most likely to add and
|
|
490
|
+
// then interpolate: D10 forbids both by name.
|
|
491
|
+
scrape(
|
|
492
|
+
"imap-worker",
|
|
493
|
+
`remit_imap_failures_total{operation="fetch",kind="auth",folder="${SENTINEL}"} 9\n`,
|
|
494
|
+
),
|
|
495
|
+
scrape(
|
|
496
|
+
"smtp-worker",
|
|
497
|
+
`remit_smtp_failures_total{kind="auth",mailbox="${SENTINEL}"} 9\n`,
|
|
498
|
+
),
|
|
499
|
+
];
|
|
500
|
+
const result = evaluate(
|
|
501
|
+
input({
|
|
502
|
+
scrapes: poisoned,
|
|
503
|
+
heartbeats: [
|
|
504
|
+
{ service: "imap-worker", ageSeconds: undefined, error: SENTINEL },
|
|
505
|
+
...HEALTHY_HEARTBEATS.slice(1),
|
|
506
|
+
],
|
|
507
|
+
previousCounters: {
|
|
508
|
+
"imap-worker:imap_auth_failures": counter(0),
|
|
509
|
+
"smtp-worker:smtp_auth_failures": counter(0),
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Every reason this deployment can produce is present, so the assertion
|
|
515
|
+
// covers the whole set rather than whichever one happened to fire.
|
|
516
|
+
assert.deepEqual(result.reasons.map((reason) => reason.code).sort(), [
|
|
517
|
+
"account_sync_stalled",
|
|
518
|
+
"dead_letter_queue_not_empty",
|
|
519
|
+
"mail_auth_failing",
|
|
520
|
+
"worker_heartbeat_stale",
|
|
521
|
+
]);
|
|
522
|
+
for (const reason of result.reasons) {
|
|
523
|
+
assert.ok(
|
|
524
|
+
!reason.summary.includes(SENTINEL),
|
|
525
|
+
`${reason.code} leaked a label value into its summary: ${reason.summary}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("is a real test — the same sentinel does reach the local-only detail", () => {
|
|
531
|
+
const result = evaluate(
|
|
532
|
+
input({
|
|
533
|
+
scrapes: [
|
|
534
|
+
scrape(
|
|
535
|
+
"backend",
|
|
536
|
+
`remit_account_sync_age_seconds{account_id="${SENTINEL}"} 99999\n`,
|
|
537
|
+
),
|
|
538
|
+
...HEALTHY_SCRAPES.slice(1),
|
|
539
|
+
],
|
|
540
|
+
}),
|
|
541
|
+
);
|
|
542
|
+
assert.match(result.reasons[0].detail ?? "", new RegExp(SENTINEL));
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe("formatDuration", () => {
|
|
547
|
+
it("renders the units an operator reads a threshold in", () => {
|
|
548
|
+
assert.equal(formatDuration(45), "45s");
|
|
549
|
+
assert.equal(formatDuration(420), "7m");
|
|
550
|
+
assert.equal(formatDuration(450), "7.5m");
|
|
551
|
+
assert.equal(formatDuration(10_800), "3h");
|
|
552
|
+
assert.equal(formatDuration(5400), "1.5h");
|
|
553
|
+
});
|
|
554
|
+
});
|