@checkstack/incident-backend 1.11.0 → 1.13.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,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();