@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.
package/src/index.ts CHANGED
@@ -145,6 +145,7 @@ export default createBackendPlugin({
145
145
  rpc: coreServices.rpc,
146
146
  rpcClient: coreServices.rpcClient,
147
147
  signalService: coreServices.signalService,
148
+ eventBus: coreServices.eventBus,
148
149
  cacheManager: coreServices.cacheManager,
149
150
  advisoryLock: coreServices.advisoryLock,
150
151
  resourceResolverRegistry: coreServices.resourceResolverRegistry,
@@ -155,6 +156,7 @@ export default createBackendPlugin({
155
156
  rpc,
156
157
  rpcClient,
157
158
  signalService,
159
+ eventBus,
158
160
  cacheManager,
159
161
  advisoryLock,
160
162
  resourceResolverRegistry,
@@ -198,6 +200,7 @@ export default createBackendPlugin({
198
200
  const router = createRouter({
199
201
  service,
200
202
  signalService,
203
+ eventBus,
201
204
  catalogClient,
202
205
  notificationClient,
203
206
  authClient,
@@ -218,6 +221,7 @@ export default createBackendPlugin({
218
221
  for (const action of createIncidentActions({
219
222
  service,
220
223
  getIncidentEntity: () => incidentEntity,
224
+ eventBus,
221
225
  })) {
222
226
  automationActions.registerAction(action, pluginMetadata);
223
227
  }
@@ -317,6 +317,187 @@ describe("notifyAffectedSystems", () => {
317
317
  });
318
318
  });
319
319
 
320
+ describe("update message in body", () => {
321
+ it("appends the escaped update message as a blockquote", async () => {
322
+ await notifyAffectedSystems({
323
+ catalogClient: mockCatalogClient as never,
324
+ notificationClient: mockNotificationClient as never,
325
+ logger: mockLogger as never,
326
+ incidentId: "inc-1",
327
+ incidentTitle: "API Outage",
328
+ systemIds: ["sys-1"],
329
+ action: "updated",
330
+ severity: "minor",
331
+ updateMessage: "Rolled back the bad deploy, monitoring recovery.",
332
+ });
333
+
334
+ const call = (
335
+ mockNotificationClient.notifyForSubscription.mock
336
+ .calls[0] as unknown as [{ body?: string }]
337
+ )[0];
338
+ expect(call?.body).toContain(
339
+ "\n\n> Rolled back the bad deploy, monitoring recovery",
340
+ );
341
+ });
342
+
343
+ it("escapes markdown control characters in the message", async () => {
344
+ await notifyAffectedSystems({
345
+ catalogClient: mockCatalogClient as never,
346
+ notificationClient: mockNotificationClient as never,
347
+ logger: mockLogger as never,
348
+ incidentId: "inc-1",
349
+ incidentTitle: "API Outage",
350
+ systemIds: ["sys-1"],
351
+ action: "updated",
352
+ severity: "minor",
353
+ updateMessage: "See [here](http://evil) **now** `code`",
354
+ });
355
+
356
+ const call = (
357
+ mockNotificationClient.notifyForSubscription.mock
358
+ .calls[0] as unknown as [{ body?: string }]
359
+ )[0];
360
+ // No unescaped link/bold/code syntax survives into the body.
361
+ expect(call?.body).not.toContain("[here](http://evil)");
362
+ expect(call?.body).not.toContain("**now**");
363
+ expect(call?.body).toContain("\\[here\\]");
364
+ expect(call?.body).toContain("\\*\\*now\\*\\*");
365
+ });
366
+
367
+ it("strips non-whitespace control characters (ESC/NUL/BEL/DEL)", async () => {
368
+ await notifyAffectedSystems({
369
+ catalogClient: mockCatalogClient as never,
370
+ notificationClient: mockNotificationClient as never,
371
+ logger: mockLogger as never,
372
+ incidentId: "inc-1",
373
+ incidentTitle: "API Outage",
374
+ systemIds: ["sys-1"],
375
+ action: "updated",
376
+ severity: "minor",
377
+ updateMessage: "before\u001B\u0000\u0007\u007Fafter",
378
+ });
379
+
380
+ const call = (
381
+ mockNotificationClient.notifyForSubscription.mock
382
+ .calls[0] as unknown as [{ body?: string }]
383
+ )[0];
384
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
385
+ expect(blockquoteLine).toBe("beforeafter");
386
+ // No control characters survive into the excerpt.
387
+ expect(/[\u0000-\u001F\u007F-\u009F]/u.test(blockquoteLine)).toBe(
388
+ false,
389
+ );
390
+ });
391
+
392
+ it("escapes HTML-significant < and & so markup cannot be injected", async () => {
393
+ await notifyAffectedSystems({
394
+ catalogClient: mockCatalogClient as never,
395
+ notificationClient: mockNotificationClient as never,
396
+ logger: mockLogger as never,
397
+ incidentId: "inc-1",
398
+ incidentTitle: "API Outage",
399
+ systemIds: ["sys-1"],
400
+ action: "updated",
401
+ severity: "minor",
402
+ updateMessage: "watch <img onerror=x> & <script>",
403
+ });
404
+
405
+ const call = (
406
+ mockNotificationClient.notifyForSubscription.mock
407
+ .calls[0] as unknown as [{ body?: string }]
408
+ )[0];
409
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
410
+ expect(blockquoteLine).not.toContain("<");
411
+ expect(blockquoteLine).toContain("&lt;img");
412
+ expect(blockquoteLine).toContain("&amp;");
413
+ });
414
+
415
+ it("collapses newlines so the message cannot break out of the blockquote", async () => {
416
+ await notifyAffectedSystems({
417
+ catalogClient: mockCatalogClient as never,
418
+ notificationClient: mockNotificationClient as never,
419
+ logger: mockLogger as never,
420
+ incidentId: "inc-1",
421
+ incidentTitle: "API Outage",
422
+ systemIds: ["sys-1"],
423
+ action: "updated",
424
+ severity: "minor",
425
+ updateMessage: "line one\n\nline two\ninjected",
426
+ });
427
+
428
+ const call = (
429
+ mockNotificationClient.notifyForSubscription.mock
430
+ .calls[0] as unknown as [{ body?: string }]
431
+ )[0];
432
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
433
+ expect(blockquoteLine).not.toContain("\n");
434
+ expect(blockquoteLine).toBe("line one line two injected");
435
+ });
436
+
437
+ it("truncates an over-long message to a bounded length", async () => {
438
+ const longMessage = "a".repeat(1000);
439
+ await notifyAffectedSystems({
440
+ catalogClient: mockCatalogClient as never,
441
+ notificationClient: mockNotificationClient as never,
442
+ logger: mockLogger as never,
443
+ incidentId: "inc-1",
444
+ incidentTitle: "API Outage",
445
+ systemIds: ["sys-1"],
446
+ action: "updated",
447
+ severity: "minor",
448
+ updateMessage: longMessage,
449
+ });
450
+
451
+ const call = (
452
+ mockNotificationClient.notifyForSubscription.mock
453
+ .calls[0] as unknown as [{ body?: string }]
454
+ )[0];
455
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
456
+ expect(blockquoteLine.endsWith("...")).toBe(true);
457
+ // 500 chars + the "..." indicator.
458
+ expect(blockquoteLine.length).toBeLessThanOrEqual(503);
459
+ });
460
+
461
+ it("omits the blockquote entirely for a blank/whitespace message", async () => {
462
+ await notifyAffectedSystems({
463
+ catalogClient: mockCatalogClient as never,
464
+ notificationClient: mockNotificationClient as never,
465
+ logger: mockLogger as never,
466
+ incidentId: "inc-1",
467
+ incidentTitle: "API Outage",
468
+ systemIds: ["sys-1"],
469
+ action: "updated",
470
+ severity: "minor",
471
+ updateMessage: " \n ",
472
+ });
473
+
474
+ const call = (
475
+ mockNotificationClient.notifyForSubscription.mock
476
+ .calls[0] as unknown as [{ body?: string }]
477
+ )[0];
478
+ expect(call?.body).not.toContain("\n\n>");
479
+ });
480
+
481
+ it("omits the blockquote when no message is provided", async () => {
482
+ await notifyAffectedSystems({
483
+ catalogClient: mockCatalogClient as never,
484
+ notificationClient: mockNotificationClient as never,
485
+ logger: mockLogger as never,
486
+ incidentId: "inc-1",
487
+ incidentTitle: "API Outage",
488
+ systemIds: ["sys-1"],
489
+ action: "created",
490
+ severity: "minor",
491
+ });
492
+
493
+ const call = (
494
+ mockNotificationClient.notifyForSubscription.mock
495
+ .calls[0] as unknown as [{ body?: string }]
496
+ )[0];
497
+ expect(call?.body).not.toContain("\n\n>");
498
+ });
499
+ });
500
+
320
501
  describe("error handling", () => {
321
502
  it("logs a warning but does not throw when the notify call fails", async () => {
322
503
  mockNotificationClient.notifyForSubscription.mockRejectedValue(
@@ -7,6 +7,7 @@ import type { Logger } from "@checkstack/backend-api";
7
7
  import type { InferClient } from "@checkstack/common";
8
8
  import { resolveRoute } from "@checkstack/common";
9
9
  import type { NotificationApi } from "@checkstack/notification-common";
10
+ import { buildUpdateMessageSuffix } from "@checkstack/notification-common";
10
11
  import {
11
12
  incidentRoutes,
12
13
  incidentCollapseKey,
@@ -29,6 +30,13 @@ export async function notifyAffectedSystems(props: {
29
30
  systemNames?: Map<string, string>;
30
31
  action: "created" | "updated" | "resolved" | "reopened";
31
32
  severity: string;
33
+ /**
34
+ * The latest incident update's free-text message. When present it is
35
+ * escaped, single-lined, truncated, and appended to the notification body as
36
+ * a blockquote so subscribers see WHAT changed, not just that something did.
37
+ * User-supplied, so it is always sanitized before it reaches a markdown body.
38
+ */
39
+ updateMessage?: string;
32
40
  }): Promise<void> {
33
41
  const {
34
42
  notificationClient,
@@ -39,6 +47,7 @@ export async function notifyAffectedSystems(props: {
39
47
  systemNames,
40
48
  action,
41
49
  severity,
50
+ updateMessage,
42
51
  } = props;
43
52
  void props.catalogClient;
44
53
 
@@ -64,12 +73,14 @@ export async function notifyAffectedSystems(props: {
64
73
  }),
65
74
  );
66
75
 
76
+ const messageSuffix = buildUpdateMessageSuffix({ message: updateMessage });
77
+
67
78
  try {
68
79
  await notificationClient.notifyForSubscription({
69
80
  specId: incidentSystemSubscription.specId,
70
81
  resourceKeys: uniqueSystemIds,
71
82
  title: `Incident ${actionText}: ${incidentTitle}`,
72
- body: `Incident **"${incidentTitle}"** has been ${actionText}.`,
83
+ body: `Incident **"${incidentTitle}"** has been ${actionText}.${messageSuffix}`,
73
84
  importance,
74
85
  action: { label: "View Incident", url: incidentDetailPath },
75
86
  collapseKey: incidentCollapseKey(incidentId),
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { AuthService, RealUser, ServiceUser } from "@checkstack/backend-api";
4
+ import {
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ type IncidentVisibility,
8
+ } from "@checkstack/incident-common";
9
+ import {
10
+ isVisibleAtAudience,
11
+ filterByAudience,
12
+ scopeEditHistory,
13
+ resolveIncidentAudience,
14
+ type ReadAudience,
15
+ } from "./read-visibility";
16
+
17
+ const MANAGE_ID = qualifyAccessRuleId(
18
+ pluginMetadata,
19
+ incidentAccess.incident.manage,
20
+ );
21
+
22
+ /** Auth stub whose `check` returns a fixed verdict and records its calls. */
23
+ function fakeAuth(hasAccess: boolean): Pick<AuthService, "check"> {
24
+ return {
25
+ check: async () => ({ hasAccess }),
26
+ };
27
+ }
28
+
29
+ describe("isVisibleAtAudience", () => {
30
+ const rows: Array<[IncidentVisibility, ReadAudience, boolean]> = [
31
+ ["public", "public", true],
32
+ ["logged_in", "public", false],
33
+ ["internal", "public", false],
34
+ ["public", "authenticated", true],
35
+ ["logged_in", "authenticated", true],
36
+ ["internal", "authenticated", false],
37
+ ["public", "manager", true],
38
+ ["logged_in", "manager", true],
39
+ ["internal", "manager", true],
40
+ ];
41
+ test.each(rows)("%s @ %s => %s", (visibility, audience, expected) => {
42
+ expect(isVisibleAtAudience(visibility, audience)).toBe(expected);
43
+ });
44
+ });
45
+
46
+ describe("filterByAudience", () => {
47
+ const items: Array<{ id: string; visibility: IncidentVisibility }> = [
48
+ { id: "p", visibility: "public" },
49
+ { id: "l", visibility: "logged_in" },
50
+ { id: "i", visibility: "internal" },
51
+ ];
52
+
53
+ test("anonymous sees only public", () => {
54
+ expect(filterByAudience(items, "public").map((i) => i.id)).toEqual(["p"]);
55
+ });
56
+ test("authenticated excludes internal", () => {
57
+ expect(filterByAudience(items, "authenticated").map((i) => i.id)).toEqual([
58
+ "p",
59
+ "l",
60
+ ]);
61
+ });
62
+ test("manager sees all", () => {
63
+ expect(filterByAudience(items, "manager").map((i) => i.id)).toEqual([
64
+ "p",
65
+ "l",
66
+ "i",
67
+ ]);
68
+ });
69
+ });
70
+
71
+ describe("scopeEditHistory", () => {
72
+ // A now-public update whose PRIOR version was internal: exposing the history
73
+ // to a non-manager would leak the prior internal content.
74
+ const updates = [
75
+ {
76
+ id: "u1",
77
+ visibility: "public" as IncidentVisibility,
78
+ editHistory: [
79
+ {
80
+ message: "secret internal note",
81
+ visibility: "internal",
82
+ createdAt: "2026-01-01T00:00:00.000Z",
83
+ editedAt: "2026-01-01T01:00:00.000Z",
84
+ },
85
+ ],
86
+ },
87
+ ];
88
+
89
+ test("manager keeps the edit history", () => {
90
+ const [u] = scopeEditHistory(updates, "manager");
91
+ expect(u.editHistory).toHaveLength(1);
92
+ });
93
+
94
+ test("authenticated non-manager gets history stripped", () => {
95
+ const [u] = scopeEditHistory(updates, "authenticated");
96
+ expect(u.editHistory).toBeUndefined();
97
+ });
98
+
99
+ test("public reader gets history stripped (no prior internal leak)", () => {
100
+ const [u] = scopeEditHistory(updates, "public");
101
+ expect(u.editHistory).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe("resolveIncidentAudience", () => {
106
+ test("no user => public", async () => {
107
+ const audience = await resolveIncidentAudience({
108
+ context: { user: undefined, auth: fakeAuth(false) },
109
+ incidentId: "inc1",
110
+ });
111
+ expect(audience).toBe("public");
112
+ });
113
+
114
+ test("service user => manager (trusted)", async () => {
115
+ const user: ServiceUser = { type: "service", pluginId: "other" };
116
+ const audience = await resolveIncidentAudience({
117
+ context: { user, auth: fakeAuth(false) },
118
+ incidentId: "inc1",
119
+ });
120
+ expect(audience).toBe("manager");
121
+ });
122
+
123
+ test("global manage rule => manager", async () => {
124
+ const user: RealUser = { type: "user", id: "u1", accessRules: [MANAGE_ID] };
125
+ const audience = await resolveIncidentAudience({
126
+ context: { user, auth: fakeAuth(false) },
127
+ incidentId: "inc1",
128
+ });
129
+ expect(audience).toBe("manager");
130
+ });
131
+
132
+ test("wildcard rule => manager", async () => {
133
+ const user: RealUser = { type: "user", id: "u1", accessRules: ["*"] };
134
+ const audience = await resolveIncidentAudience({
135
+ context: { user, auth: fakeAuth(false) },
136
+ incidentId: "inc1",
137
+ });
138
+ expect(audience).toBe("manager");
139
+ });
140
+
141
+ test("team grant on this incident => manager", async () => {
142
+ const user: RealUser = { type: "user", id: "u1", accessRules: [] };
143
+ const audience = await resolveIncidentAudience({
144
+ context: { user, auth: fakeAuth(true) },
145
+ incidentId: "inc1",
146
+ });
147
+ expect(audience).toBe("manager");
148
+ });
149
+
150
+ test("authenticated non-manager => authenticated", async () => {
151
+ const user: RealUser = { type: "user", id: "u1", accessRules: [] };
152
+ const audience = await resolveIncidentAudience({
153
+ context: { user, auth: fakeAuth(false) },
154
+ incidentId: "inc1",
155
+ });
156
+ expect(audience).toBe("authenticated");
157
+ });
158
+ });
@@ -0,0 +1,99 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcContext, AuthService } from "@checkstack/backend-api";
3
+ import {
4
+ incidentAccess,
5
+ incidentResourceTypes,
6
+ pluginMetadata,
7
+ type IncidentVisibility,
8
+ } from "@checkstack/incident-common";
9
+
10
+ /**
11
+ * Read-path audience level for the caller. Drives which incident updates/links
12
+ * ship in a payload (Item 3/5): filtering is enforced SERVER-SIDE here, never
13
+ * via CSS - a hidden item is never serialized.
14
+ *
15
+ * - `public`: anonymous callers and the public status-page projection.
16
+ * - `authenticated`: a logged-in user who cannot manage this incident.
17
+ * - `manager`: a global incident manager, a team-scoped manager of THIS
18
+ * incident, or a trusted service call.
19
+ */
20
+ export type ReadAudience = "public" | "authenticated" | "manager";
21
+
22
+ /** Whether an item of the given visibility is exposed at the caller's audience. */
23
+ export function isVisibleAtAudience(
24
+ visibility: IncidentVisibility,
25
+ audience: ReadAudience,
26
+ ): boolean {
27
+ if (audience === "manager") return true;
28
+ if (audience === "authenticated") return visibility !== "internal";
29
+ return visibility === "public";
30
+ }
31
+
32
+ /** Filter a list of visibility-carrying items to what the audience may see. */
33
+ export function filterByAudience<T extends { visibility: IncidentVisibility }>(
34
+ items: T[],
35
+ audience: ReadAudience,
36
+ ): T[] {
37
+ return items.filter((item) => isVisibleAtAudience(item.visibility, audience));
38
+ }
39
+
40
+ /**
41
+ * Strip the manager-only `editHistory` from updates for any non-manager
42
+ * audience. An update's CURRENT visibility gates the whole row, but a PRIOR
43
+ * version archived in `editHistory` could have been `internal` before being made
44
+ * `public` - so exposing history to a public / logged-in reader would leak prior
45
+ * internal content. The simplest safe rule (and the one applied here): edit
46
+ * history is manager-only. Managers get the array untouched; everyone else gets
47
+ * it removed from the payload.
48
+ */
49
+ export function scopeEditHistory<T extends { editHistory?: unknown }>(
50
+ updates: T[],
51
+ audience: ReadAudience,
52
+ ): T[] {
53
+ if (audience === "manager") return updates;
54
+ return updates.map((u) => ({ ...u, editHistory: undefined }));
55
+ }
56
+
57
+ /**
58
+ * Resolve the caller's audience for a specific incident. A trusted service call
59
+ * is treated as `manager` (server-to-server reads are not the public surface -
60
+ * the public status page filters to `public` separately in its widget). An
61
+ * anonymous caller (no user) is `public`. Otherwise the caller is a `manager`
62
+ * when they hold the global manage rule OR a team grant to manage THIS incident.
63
+ */
64
+ export async function resolveIncidentAudience({
65
+ context,
66
+ incidentId,
67
+ }: {
68
+ // Only the caller + the auth `check` are needed; a narrow shape keeps this
69
+ // unit testable without constructing a full RpcContext / AuthService.
70
+ context: { user: RpcContext["user"]; auth: Pick<AuthService, "check"> };
71
+ incidentId: string;
72
+ }): Promise<ReadAudience> {
73
+ const user = context.user;
74
+ if (!user) return "public";
75
+ if (user.type === "service") return "manager";
76
+
77
+ const manageRuleId = qualifyAccessRuleId(
78
+ pluginMetadata,
79
+ incidentAccess.incident.manage,
80
+ );
81
+ const rules = user.accessRules ?? [];
82
+ const hasGlobalManage =
83
+ rules.includes("*") || rules.includes(manageRuleId);
84
+ if (hasGlobalManage) return "manager";
85
+
86
+ if (user.type === "user" || user.type === "application") {
87
+ const { hasAccess } = await context.auth.check({
88
+ userId: user.id,
89
+ userType: user.type,
90
+ objectType: incidentResourceTypes.incident,
91
+ objectId: incidentId,
92
+ action: "manage",
93
+ hasGlobalAccess: false,
94
+ });
95
+ if (hasAccess) return "manager";
96
+ }
97
+
98
+ return "authenticated";
99
+ }
@@ -25,6 +25,7 @@ interface FakeIncident {
25
25
  status: string;
26
26
  severity: string;
27
27
  suppressNotifications: boolean;
28
+ healthOverride: string | null;
28
29
  systemIds: string[];
29
30
  createdAt: Date;
30
31
  updatedAt: Date;
@@ -39,6 +40,7 @@ function makeIncident(id: string, status = "investigating"): FakeIncident {
39
40
  status,
40
41
  severity: "major",
41
42
  suppressNotifications: false,
43
+ healthOverride: null,
42
44
  systemIds: ["sys-1"],
43
45
  createdAt: new Date("2026-01-01T00:00:00Z"),
44
46
  updatedAt: new Date("2026-01-01T00:00:00Z"),
@@ -63,15 +65,22 @@ function buildRouter() {
63
65
  const getIncident = mock(async (id: string) =>
64
66
  PRESENT.has(id) ? makeIncident(id) : undefined,
65
67
  );
68
+ // Echoes the patch back onto the incident so an override-only edit still
69
+ // resolves the affected systemIds for the lifecycle hook payload.
70
+ const updateIncident = mock(async (input: { id: string }) =>
71
+ PRESENT.has(input.id) ? makeIncident(input.id) : undefined,
72
+ );
66
73
 
67
74
  const service = {
68
75
  getIncident,
69
76
  deleteIncident,
70
77
  resolveIncident,
78
+ updateIncident,
71
79
  } as unknown as Parameters<typeof createRouter>[0]["service"];
72
80
 
73
81
  const invalidateForMutation = mock(async () => {});
74
82
  const broadcast = mock(async () => {});
83
+ const emit = mock(async () => {});
75
84
  const notifyForSubscription = mock(async () => {});
76
85
  const getSystem = mock(async () => undefined);
77
86
  const getUserById = mock(async () => undefined);
@@ -82,6 +91,9 @@ function buildRouter() {
82
91
  signalService: { broadcast } as unknown as Parameters<
83
92
  typeof createRouter
84
93
  >[0]["signalService"],
94
+ eventBus: { emit } as unknown as Parameters<
95
+ typeof createRouter
96
+ >[0]["eventBus"],
85
97
  catalogClient: { getSystem } as unknown as Parameters<
86
98
  typeof createRouter
87
99
  >[0]["catalogClient"],
@@ -97,7 +109,15 @@ function buildRouter() {
97
109
  >[0]["cache"],
98
110
  });
99
111
 
100
- return { router, deleteIncident, resolveIncident, invalidateForMutation };
112
+ return {
113
+ router,
114
+ deleteIncident,
115
+ resolveIncident,
116
+ updateIncident,
117
+ emit,
118
+ invalidateForMutation,
119
+ notifyForSubscription,
120
+ };
101
121
  }
102
122
 
103
123
  /** Team-scoped context: no global rule; only `granted` ids are grant-covered. */
@@ -154,6 +174,54 @@ describe("incident router bulkDeleteIncidents", () => {
154
174
  });
155
175
  });
156
176
 
177
+ describe("incident router resolveIncident notification", () => {
178
+ it("carries the resolution note into the subscriber notification body", async () => {
179
+ const { router, notifyForSubscription } = buildRouter();
180
+ const ctx = teamScopedContext(["inc-ok"]);
181
+
182
+ await call(
183
+ router.resolveIncident,
184
+ { id: "inc-ok", message: "Root cause fixed and services restored" },
185
+ { context: ctx },
186
+ );
187
+
188
+ expect(notifyForSubscription).toHaveBeenCalledTimes(1);
189
+ const payload = (
190
+ notifyForSubscription.mock.calls[0] as unknown[] | undefined
191
+ )?.[0] as { body?: string } | undefined;
192
+ expect(payload?.body).toContain("has been resolved");
193
+ // The operator's resolution note must reach subscribers, not be dropped.
194
+ expect(payload?.body).toContain("Root cause fixed and services restored");
195
+ });
196
+ });
197
+
198
+ describe("incident lifecycle hook", () => {
199
+ it("fires incident.lifecycle.changed on an override-only update (with affected systemIds)", async () => {
200
+ // The reactive `incident` entity state is {status, severity, systemIds}, so
201
+ // clearing/adding a healthOverride with no other change emits no entity
202
+ // change. This hook MUST still fire so SLO can open/close incident-forced
203
+ // downtime — the whole reason it exists alongside INCIDENT_UPDATED.
204
+ const { router, emit } = buildRouter();
205
+ const ctx = teamScopedContext(["inc-ok"]);
206
+
207
+ await call(
208
+ router.updateIncident,
209
+ { id: "inc-ok", healthOverride: null },
210
+ { context: ctx },
211
+ );
212
+
213
+ expect(emit).toHaveBeenCalledTimes(1);
214
+ const emitArgs = emit.mock.calls[0] as unknown[] | undefined;
215
+ const hook = emitArgs?.[0] as { id?: string } | undefined;
216
+ const payload = emitArgs?.[1] as
217
+ | { systemIds?: string[]; action?: string }
218
+ | undefined;
219
+ expect(hook?.id).toBe("incident.lifecycle.changed");
220
+ expect(payload?.action).toBe("updated");
221
+ expect(payload?.systemIds).toEqual(["sys-1"]);
222
+ });
223
+ });
224
+
157
225
  describe("incident router bulkResolveIncidents", () => {
158
226
  it("resolves only authorized ids and reports forbidden/notFound/error per id", async () => {
159
227
  const { router, resolveIncident } = buildRouter();