@checkstack/incident-backend 1.10.0 → 1.12.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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Integration test for IncidentService.removeLink against a REAL Postgres,
3
+ * exercising the anti-spoof WHERE clause (`WHERE id AND incidentId`) that the
4
+ * mocked-DB unit tests cannot. removeLink is authorized with `idParam:
5
+ * "incidentId"`, so the caller proves a grant on the PARENT incident. The
6
+ * service MUST additionally scope the delete by that incidentId, or a caller
7
+ * could pair a link id belonging to incident A with an incident B they manage
8
+ * and delete A's link (an IDOR). These tests prove the scoping holds at the SQL
9
+ * layer: a mismatched incidentId removes nothing and returns undefined; only a
10
+ * matched pair deletes.
11
+ *
12
+ * Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
13
+ * skipped in the default `bun test` run, matching the other *.it.test.ts here.
14
+ */
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
16
+ import { drizzle } from "drizzle-orm/node-postgres";
17
+ import { Pool } from "pg";
18
+ import type {
19
+ AdvisoryLockService,
20
+ SafeDatabase,
21
+ } from "@checkstack/backend-api";
22
+ import * as schema from "./schema";
23
+ import { IncidentService } from "./service";
24
+
25
+ const PG_URL =
26
+ process.env.CHECKSTACK_IT_PG_URL ??
27
+ "postgres://postgres:postgres@localhost:5432/postgres";
28
+ const SCHEMA = "incident_it_removelink";
29
+
30
+ // removeLink never touches the advisory lock, so a no-op faithful stub is all
31
+ // the constructor needs (no cast).
32
+ const noopAdvisoryLock: AdvisoryLockService = {
33
+ tryAcquire: async () => null,
34
+ withXactLock: async ({ fn }) => fn(),
35
+ };
36
+
37
+ let admin: Pool;
38
+ let pool: Pool;
39
+ let service: IncidentService;
40
+
41
+ async function insertLink(row: {
42
+ id: string;
43
+ incidentId: string;
44
+ url: string;
45
+ }): Promise<void> {
46
+ await pool.query(
47
+ `INSERT INTO "${SCHEMA}".incident_links (id, incident_id, url)
48
+ VALUES ($1, $2, $3)`,
49
+ [row.id, row.incidentId, row.url],
50
+ );
51
+ }
52
+
53
+ async function linkExists(id: string): Promise<boolean> {
54
+ const res = await pool.query(
55
+ `SELECT 1 FROM "${SCHEMA}".incident_links WHERE id = $1`,
56
+ [id],
57
+ );
58
+ return res.rowCount === 1;
59
+ }
60
+
61
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
62
+ "IncidentService.removeLink anti-spoof scoping (shared Postgres)",
63
+ () => {
64
+ beforeAll(async () => {
65
+ admin = new Pool({ connectionString: PG_URL });
66
+ await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
67
+ await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
68
+ // Only the incident_links table is exercised; no FK to incidents so the
69
+ // DDL stays minimal and focused on the WHERE-clause behavior.
70
+ await admin.query(
71
+ `CREATE TABLE "${SCHEMA}".incident_links (
72
+ id text PRIMARY KEY,
73
+ incident_id text NOT NULL,
74
+ label text,
75
+ url text NOT NULL,
76
+ visibility text NOT NULL DEFAULT 'public',
77
+ created_at timestamp NOT NULL DEFAULT now()
78
+ )`,
79
+ );
80
+ pool = new Pool({
81
+ connectionString: PG_URL,
82
+ options: `-c search_path=${SCHEMA}`,
83
+ });
84
+ const db = drizzle(pool, {
85
+ schema,
86
+ }) as unknown as SafeDatabase<typeof schema>;
87
+ service = new IncidentService(db, noopAdvisoryLock);
88
+ });
89
+
90
+ afterAll(async () => {
91
+ await pool?.end();
92
+ await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
93
+ await admin.end();
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ await pool.query(`TRUNCATE "${SCHEMA}".incident_links`);
98
+ });
99
+
100
+ it("removes the link and returns the incidentId when the pair matches", async () => {
101
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
102
+
103
+ expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
104
+ expect(await linkExists("lnk-1")).toBe(false);
105
+ });
106
+
107
+ it("removes nothing and returns undefined when the incidentId does not own the link", async () => {
108
+ // Link belongs to inc-1; the caller is authorized against inc-2 (a
109
+ // different incident they manage). The spoofed pair must NOT delete.
110
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
111
+
112
+ expect(await service.removeLink("lnk-1", "inc-2")).toBeUndefined();
113
+ // The link is untouched.
114
+ expect(await linkExists("lnk-1")).toBe(true);
115
+ });
116
+
117
+ it("returns undefined for a link id that does not exist", async () => {
118
+ expect(await service.removeLink("missing", "inc-1")).toBeUndefined();
119
+ });
120
+
121
+ it("only deletes the link matching BOTH id and incidentId", async () => {
122
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
123
+ await insertLink({ id: "lnk-2", incidentId: "inc-2", url: "https://b" });
124
+
125
+ expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
126
+ expect(await linkExists("lnk-1")).toBe(false);
127
+ // The other incident's link is untouched.
128
+ expect(await linkExists("lnk-2")).toBe(true);
129
+ });
130
+
131
+ it("updateLink edits the link when the pair matches", async () => {
132
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
133
+
134
+ const updated = await service.updateLink({
135
+ id: "lnk-1",
136
+ incidentId: "inc-1",
137
+ label: "Runbook",
138
+ url: "https://b",
139
+ visibility: "internal",
140
+ });
141
+ expect(updated?.label).toBe("Runbook");
142
+ expect(updated?.url).toBe("https://b");
143
+ expect(updated?.visibility).toBe("internal");
144
+
145
+ const res = await pool.query(
146
+ `SELECT url, label, visibility FROM "${SCHEMA}".incident_links WHERE id = $1`,
147
+ ["lnk-1"],
148
+ );
149
+ expect(res.rows[0]).toEqual({
150
+ url: "https://b",
151
+ label: "Runbook",
152
+ visibility: "internal",
153
+ });
154
+ });
155
+
156
+ it("updateLink edits nothing and returns undefined when the incidentId does not own the link", async () => {
157
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
158
+
159
+ // Link belongs to inc-1; the caller is authorized against inc-2. The
160
+ // spoofed pair must NOT edit.
161
+ expect(
162
+ await service.updateLink({
163
+ id: "lnk-1",
164
+ incidentId: "inc-2",
165
+ url: "https://evil",
166
+ }),
167
+ ).toBeUndefined();
168
+
169
+ const res = await pool.query(
170
+ `SELECT url FROM "${SCHEMA}".incident_links WHERE id = $1`,
171
+ ["lnk-1"],
172
+ );
173
+ // The link is untouched.
174
+ expect(res.rows[0].url).toBe("https://a");
175
+ });
176
+
177
+ it("updateLink returns undefined for a link id that does not exist", async () => {
178
+ expect(
179
+ await service.updateLink({
180
+ id: "missing",
181
+ incidentId: "inc-1",
182
+ url: "https://x",
183
+ }),
184
+ ).toBeUndefined();
185
+ });
186
+ },
187
+ );
@@ -70,8 +70,16 @@ function createProgrammableSelectDb(resultsByCall: unknown[][]) {
70
70
  return { from };
71
71
  });
72
72
 
73
+ // Read-batching methods (getManyEntityStates, listOpenIncidentsBySystem, ...)
74
+ // now run their reads inside `withScopedTransaction` -> `db.transaction(fn)`.
75
+ // The tx exposes the SAME `select` mock so the per-invocation call counter is
76
+ // shared and `getCallCount()` still reflects total queries issued.
77
+ const transaction = mock((fn: (tx: { select: typeof select }) => unknown) =>
78
+ Promise.resolve(fn({ select })),
79
+ );
80
+
73
81
  return {
74
- db: { select } as unknown,
82
+ db: { select, transaction } as unknown,
75
83
  select,
76
84
  getCallCount: () => callIndex,
77
85
  };
@@ -227,6 +235,232 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
227
235
  });
228
236
  });
229
237
 
238
+ describe("IncidentService.getIncident (batched detail read)", () => {
239
+ const createdAt = new Date("2026-06-01T10:00:00.000Z");
240
+ const updatedAt = new Date("2026-06-01T10:05:00.000Z");
241
+
242
+ it("assembles the detail from incident + systems + updates + links (4 queries, one tx)", async () => {
243
+ const dbHelper = createProgrammableSelectDb([
244
+ // 1st: the incident row.
245
+ [
246
+ {
247
+ id: "inc-1",
248
+ title: "DB down",
249
+ description: null,
250
+ status: "investigating",
251
+ severity: "critical",
252
+ suppressNotifications: false,
253
+ healthOverride: null,
254
+ createdAt,
255
+ updatedAt,
256
+ },
257
+ ],
258
+ // 2nd: system associations.
259
+ [{ systemId: "sys-a" }, { systemId: "sys-b" }],
260
+ // 3rd: the full (unfiltered) timeline.
261
+ [
262
+ {
263
+ id: "u1",
264
+ incidentId: "inc-1",
265
+ message: "looking",
266
+ statusChange: "investigating",
267
+ visibility: "public",
268
+ createdAt,
269
+ editedAt: null,
270
+ createdBy: null,
271
+ },
272
+ ],
273
+ // 4th: hotlinks.
274
+ [
275
+ {
276
+ id: "lnk-1",
277
+ incidentId: "inc-1",
278
+ label: "Runbook",
279
+ url: "https://a",
280
+ visibility: "public",
281
+ createdAt,
282
+ },
283
+ ],
284
+ ]);
285
+ const service = new IncidentService(
286
+ dbHelper.db as never,
287
+ makeFakeAdvisoryLock(),
288
+ );
289
+
290
+ const out = await service.getIncident("inc-1");
291
+
292
+ expect(out?.systemIds).toEqual(["sys-a", "sys-b"]);
293
+ // null description normalized to undefined, nullable update fields too.
294
+ expect(out?.description).toBeUndefined();
295
+ expect(out?.updates).toEqual([
296
+ {
297
+ id: "u1",
298
+ incidentId: "inc-1",
299
+ message: "looking",
300
+ statusChange: "investigating",
301
+ visibility: "public",
302
+ createdAt,
303
+ editedAt: undefined,
304
+ editHistory: [],
305
+ createdBy: undefined,
306
+ },
307
+ ]);
308
+ expect(out?.links.map((l) => l.id)).toEqual(["lnk-1"]);
309
+ // Exactly the 4 reads (no per-row fan-out), all issued on the tx.
310
+ expect(dbHelper.getCallCount()).toBe(4);
311
+ });
312
+
313
+ it("returns undefined after a single query when the incident is absent", async () => {
314
+ const dbHelper = createProgrammableSelectDb([[]]);
315
+ const service = new IncidentService(
316
+ dbHelper.db as never,
317
+ makeFakeAdvisoryLock(),
318
+ );
319
+ expect(await service.getIncident("ghost")).toBeUndefined();
320
+ // The systems/updates/links reads are skipped once the incident is missing.
321
+ expect(dbHelper.getCallCount()).toBe(1);
322
+ });
323
+ });
324
+
325
+ describe("IncidentService.listIncidents (set-based system grouping)", () => {
326
+ const createdAt = new Date("2026-06-01T10:00:00.000Z");
327
+ const updatedAt = new Date("2026-06-01T10:05:00.000Z");
328
+ const incidentRow = (id: string, description: string | null) => ({
329
+ id,
330
+ title: id,
331
+ description,
332
+ status: "investigating" as const,
333
+ severity: "major" as const,
334
+ suppressNotifications: false,
335
+ healthOverride: null,
336
+ createdAt,
337
+ updatedAt,
338
+ });
339
+
340
+ it("fetches all system associations in ONE junction query (no N+1) and groups them", async () => {
341
+ const dbHelper = createProgrammableSelectDb([
342
+ // 1st: the incidents matching the status filter.
343
+ [incidentRow("inc-1", null), incidentRow("inc-2", "elevated latency")],
344
+ // 2nd (and ONLY): a single inArray junction read for BOTH incidents.
345
+ [
346
+ { incidentId: "inc-1", systemId: "sys-a" },
347
+ { incidentId: "inc-1", systemId: "sys-b" },
348
+ { incidentId: "inc-2", systemId: "sys-c" },
349
+ ],
350
+ ]);
351
+ const service = new IncidentService(
352
+ dbHelper.db as never,
353
+ makeFakeAdvisoryLock(),
354
+ );
355
+
356
+ const out = await service.listIncidents({ includeResolved: true });
357
+
358
+ expect(out.map((i) => i.id)).toEqual(["inc-1", "inc-2"]);
359
+ expect(out[0].systemIds).toEqual(["sys-a", "sys-b"]);
360
+ expect(out[1].systemIds).toEqual(["sys-c"]);
361
+ expect(out[0].description).toBeUndefined();
362
+ expect(out[1].description).toBe("elevated latency");
363
+ // 1 incidents read + exactly 1 junction read, regardless of row count.
364
+ expect(dbHelper.getCallCount()).toBe(2);
365
+ });
366
+
367
+ it("skips the junction query when no incidents match", async () => {
368
+ const dbHelper = createProgrammableSelectDb([[]]);
369
+ const service = new IncidentService(
370
+ dbHelper.db as never,
371
+ makeFakeAdvisoryLock(),
372
+ );
373
+ expect(await service.listIncidents()).toEqual([]);
374
+ expect(dbHelper.getCallCount()).toBe(1);
375
+ });
376
+
377
+ it("resolves the system's incident ids first when filtering by systemId (3 queries total)", async () => {
378
+ const dbHelper = createProgrammableSelectDb([
379
+ // 1st: incident ids attached to the system.
380
+ [{ incidentId: "inc-1" }],
381
+ // 2nd: the incidents themselves (status-filtered).
382
+ [incidentRow("inc-1", null)],
383
+ // 3rd: the single junction grouping read.
384
+ [{ incidentId: "inc-1", systemId: "sys-a" }],
385
+ ]);
386
+ const service = new IncidentService(
387
+ dbHelper.db as never,
388
+ makeFakeAdvisoryLock(),
389
+ );
390
+
391
+ const out = await service.listIncidents({ systemId: "sys-a" });
392
+
393
+ expect(out.map((i) => i.id)).toEqual(["inc-1"]);
394
+ expect(out[0].systemIds).toEqual(["sys-a"]);
395
+ expect(dbHelper.getCallCount()).toBe(3);
396
+ });
397
+ });
398
+
399
+ describe("IncidentService.getIncidentsForSystem (set-based system grouping)", () => {
400
+ const createdAt = new Date("2026-06-01T10:00:00.000Z");
401
+ const updatedAt = new Date("2026-06-01T10:05:00.000Z");
402
+
403
+ it("groups memberships from ONE junction query after resolving the system's incidents", async () => {
404
+ const dbHelper = createProgrammableSelectDb([
405
+ // 1st: incident ids attached to the system.
406
+ [{ incidentId: "inc-1" }, { incidentId: "inc-2" }],
407
+ // 2nd: non-resolved incidents for those ids.
408
+ [
409
+ {
410
+ id: "inc-1",
411
+ title: "A",
412
+ description: null,
413
+ status: "investigating",
414
+ severity: "major",
415
+ suppressNotifications: false,
416
+ healthOverride: null,
417
+ createdAt,
418
+ updatedAt,
419
+ },
420
+ {
421
+ id: "inc-2",
422
+ title: "B",
423
+ description: null,
424
+ status: "monitoring",
425
+ severity: "minor",
426
+ suppressNotifications: false,
427
+ healthOverride: null,
428
+ createdAt,
429
+ updatedAt,
430
+ },
431
+ ],
432
+ // 3rd (and ONLY) junction read for BOTH incidents' full membership.
433
+ [
434
+ { incidentId: "inc-1", systemId: "sys-a" },
435
+ { incidentId: "inc-1", systemId: "sys-b" },
436
+ { incidentId: "inc-2", systemId: "sys-a" },
437
+ ],
438
+ ]);
439
+ const service = new IncidentService(
440
+ dbHelper.db as never,
441
+ makeFakeAdvisoryLock(),
442
+ );
443
+
444
+ const out = await service.getIncidentsForSystem("sys-a");
445
+
446
+ expect(out.map((i) => i.id)).toEqual(["inc-1", "inc-2"]);
447
+ // Each incident carries its FULL membership, not just the queried system.
448
+ expect(out[0].systemIds).toEqual(["sys-a", "sys-b"]);
449
+ expect(out[1].systemIds).toEqual(["sys-a"]);
450
+ expect(dbHelper.getCallCount()).toBe(3);
451
+ });
452
+
453
+ it("returns [] without a junction query when the system has no incidents", async () => {
454
+ const dbHelper = createProgrammableSelectDb([[]]);
455
+ const service = new IncidentService(
456
+ dbHelper.db as never,
457
+ makeFakeAdvisoryLock(),
458
+ );
459
+ expect(await service.getIncidentsForSystem("sys-x")).toEqual([]);
460
+ expect(dbHelper.getCallCount()).toBe(1);
461
+ });
462
+ });
463
+
230
464
  describe("IncidentService.listOpenIncidentsBySystem (global signals read)", () => {
231
465
  it("returns {} without a junction query when no open incidents exist", async () => {
232
466
  const dbHelper = createProgrammableSelectDb([