@checkstack/anomaly-backend 1.1.9 → 1.2.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.
@@ -0,0 +1,290 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { AnomalyService } from "./service";
3
+ import * as schema from "./schema";
4
+
5
+ /**
6
+ * Exercises the global (per-row) suppression surface on AnomalyService:
7
+ * suppress snapshots the observed value + baseline, unsuppress clears them,
8
+ * getAnomalies honours the suppression filter, and — critically — both
9
+ * mutations are scoped by `(anomalyId, systemId)` so a caller authorized on
10
+ * one system cannot mutate another system's row (IDOR). The detector-side
11
+ * auto-unsuppress lives in detector.test.ts.
12
+ */
13
+
14
+ /**
15
+ * Walk a drizzle WHERE condition object and collect every embedded scalar
16
+ * value. Lets the mock faithfully simulate Postgres row scoping: a query for
17
+ * `and(eq(id, X), eq(systemId, Y))` returns the stored row only when both X
18
+ * and Y match it (just like the real `WHERE id = X AND system_id = Y`).
19
+ */
20
+ function collectConditionValues(node: unknown): Set<string> {
21
+ const acc = new Set<string>();
22
+ const seen = new Set<unknown>();
23
+ const walk = (x: unknown): void => {
24
+ if (x == null || typeof x !== "object" || seen.has(x)) return;
25
+ seen.add(x);
26
+ const rec = x as Record<string, unknown>;
27
+ if (
28
+ "value" in rec &&
29
+ (typeof rec.value === "string" || typeof rec.value === "number")
30
+ ) {
31
+ acc.add(String(rec.value));
32
+ }
33
+ for (const key of Object.keys(rec)) walk(rec[key]);
34
+ };
35
+ walk(node);
36
+ return acc;
37
+ }
38
+
39
+ function createMockDb({
40
+ storedRow,
41
+ }: {
42
+ storedRow?: Record<string, unknown>;
43
+ } = {}) {
44
+ const updateCalls: Array<Record<string, unknown>> = [];
45
+ const whereSnapshots: unknown[] = [];
46
+
47
+ /**
48
+ * Returns the stored row only when the condition mentions every scalar
49
+ * identity field the row carries that the service scopes on (id + systemId).
50
+ * If the condition is absent (e.g. unfiltered getAnomalies) the row passes.
51
+ */
52
+ const rowsFor = (cond: unknown): Record<string, unknown>[] => {
53
+ if (!storedRow) return [];
54
+ if (cond === undefined) return [storedRow];
55
+ const values = collectConditionValues(cond);
56
+ const id = storedRow.id;
57
+ const systemId = storedRow.systemId;
58
+ const idMatches = id === undefined || values.has(String(id));
59
+ const systemMatches =
60
+ systemId === undefined || values.has(String(systemId));
61
+ return idMatches && systemMatches ? [storedRow] : [];
62
+ };
63
+
64
+ const makeThenable = (rows: unknown[]) => {
65
+ const promise = Promise.resolve(rows);
66
+ return {
67
+ then: promise.then.bind(promise),
68
+ catch: promise.catch.bind(promise),
69
+ limit: mock(() => Promise.resolve(rows)),
70
+ orderBy: mock(() => ({
71
+ limit: mock(() => Promise.resolve(rows)),
72
+ })),
73
+ };
74
+ };
75
+
76
+ const db = {
77
+ select: mock(() => ({
78
+ from: mock(() => ({
79
+ where: mock((cond: unknown) => {
80
+ whereSnapshots.push(cond);
81
+ return makeThenable(rowsFor(cond));
82
+ }),
83
+ // Unfiltered consumption (no .where()) — returns the row unscoped.
84
+ ...makeThenable(storedRow ? [storedRow] : []),
85
+ })),
86
+ })),
87
+ update: mock(() => ({
88
+ set: mock((values: Record<string, unknown>) => ({
89
+ where: mock(() => {
90
+ updateCalls.push(values);
91
+ return Promise.resolve();
92
+ }),
93
+ })),
94
+ })),
95
+ _updateCalls: updateCalls,
96
+ _whereSnapshots: whereSnapshots,
97
+ };
98
+ return db;
99
+ }
100
+
101
+ const confirmedRow = {
102
+ id: "a1",
103
+ systemId: "sys-A",
104
+ state: "anomaly" as const,
105
+ observedValue: "250",
106
+ baselineValue: 100,
107
+ };
108
+
109
+ describe("AnomalyService.suppressAnomaly", () => {
110
+ test("snapshots observed value + baseline and sets suppressedAt", async () => {
111
+ const db = createMockDb({ storedRow: confirmedRow });
112
+ const service = new AnomalyService(db as never);
113
+
114
+ const ok = await service.suppressAnomaly({
115
+ anomalyId: "a1",
116
+ systemId: "sys-A",
117
+ });
118
+
119
+ expect(ok).toBe(true);
120
+ expect(db._updateCalls.length).toBe(1);
121
+ expect(db._updateCalls[0]).toMatchObject({
122
+ suppressedValue: 250,
123
+ suppressedBaseline: 100,
124
+ });
125
+ expect(db._updateCalls[0].suppressedAt).toBeInstanceOf(Date);
126
+ });
127
+
128
+ test("stores null suppressedValue for a non-numeric observed value", async () => {
129
+ const db = createMockDb({
130
+ storedRow: {
131
+ id: "a2",
132
+ systemId: "sys-A",
133
+ state: "anomaly",
134
+ observedValue: "ERROR",
135
+ baselineValue: null,
136
+ },
137
+ });
138
+ const service = new AnomalyService(db as never);
139
+
140
+ await service.suppressAnomaly({ anomalyId: "a2", systemId: "sys-A" });
141
+
142
+ expect(db._updateCalls[0].suppressedValue).toBeNull();
143
+ });
144
+
145
+ test("returns false (no write) when the row does not exist", async () => {
146
+ const db = createMockDb();
147
+ const service = new AnomalyService(db as never);
148
+
149
+ const ok = await service.suppressAnomaly({
150
+ anomalyId: "missing",
151
+ systemId: "sys-A",
152
+ });
153
+
154
+ expect(ok).toBe(false);
155
+ expect(db._updateCalls.length).toBe(0);
156
+ });
157
+
158
+ // ─── IDOR regression ──────────────────────────────────────────────────
159
+ test("cannot suppress a row belonging to a different system", async () => {
160
+ // Row b1 belongs to system B; attacker authorized on system A passes B's id.
161
+ const db = createMockDb({
162
+ storedRow: {
163
+ id: "b1",
164
+ systemId: "sys-B",
165
+ state: "anomaly",
166
+ observedValue: "250",
167
+ baselineValue: 100,
168
+ },
169
+ });
170
+ const service = new AnomalyService(db as never);
171
+
172
+ const ok = await service.suppressAnomaly({
173
+ anomalyId: "b1",
174
+ systemId: "sys-A",
175
+ });
176
+
177
+ expect(ok).toBe(false);
178
+ expect(db._updateCalls.length).toBe(0);
179
+ });
180
+
181
+ // ─── State guard ──────────────────────────────────────────────────────
182
+ test("refuses to suppress a non-confirmed (suspicious) row", async () => {
183
+ const db = createMockDb({
184
+ storedRow: {
185
+ id: "a3",
186
+ systemId: "sys-A",
187
+ state: "suspicious",
188
+ observedValue: "250",
189
+ baselineValue: 100,
190
+ },
191
+ });
192
+ const service = new AnomalyService(db as never);
193
+
194
+ const ok = await service.suppressAnomaly({
195
+ anomalyId: "a3",
196
+ systemId: "sys-A",
197
+ });
198
+
199
+ expect(ok).toBe(false);
200
+ expect(db._updateCalls.length).toBe(0);
201
+ });
202
+ });
203
+
204
+ describe("AnomalyService.unsuppressAnomaly", () => {
205
+ test("clears all suppression columns", async () => {
206
+ const db = createMockDb({ storedRow: confirmedRow });
207
+ const service = new AnomalyService(db as never);
208
+
209
+ const ok = await service.unsuppressAnomaly({
210
+ anomalyId: "a1",
211
+ systemId: "sys-A",
212
+ });
213
+
214
+ expect(ok).toBe(true);
215
+ expect(db._updateCalls[0]).toMatchObject({
216
+ suppressedAt: null,
217
+ suppressedValue: null,
218
+ suppressedBaseline: null,
219
+ });
220
+ });
221
+
222
+ test("returns false when the row does not exist", async () => {
223
+ const db = createMockDb();
224
+ const service = new AnomalyService(db as never);
225
+
226
+ expect(
227
+ await service.unsuppressAnomaly({ anomalyId: "x", systemId: "sys-A" }),
228
+ ).toBe(false);
229
+ expect(db._updateCalls.length).toBe(0);
230
+ });
231
+
232
+ // ─── IDOR regression ──────────────────────────────────────────────────
233
+ test("cannot unsuppress a row belonging to a different system", async () => {
234
+ const db = createMockDb({
235
+ storedRow: {
236
+ id: "b1",
237
+ systemId: "sys-B",
238
+ state: "anomaly",
239
+ observedValue: "250",
240
+ suppressedAt: new Date(),
241
+ },
242
+ });
243
+ const service = new AnomalyService(db as never);
244
+
245
+ const ok = await service.unsuppressAnomaly({
246
+ anomalyId: "b1",
247
+ systemId: "sys-A",
248
+ });
249
+
250
+ expect(ok).toBe(false);
251
+ expect(db._updateCalls.length).toBe(0);
252
+ });
253
+ });
254
+
255
+ describe("AnomalyService.getAnomalies suppression filter", () => {
256
+ test("default ('active') adds a suppressedAt IS NULL predicate", async () => {
257
+ const db = createMockDb();
258
+ const service = new AnomalyService(db as never);
259
+
260
+ await service.getAnomalies({ systemId: "sys-1" });
261
+
262
+ // A where() with a non-undefined condition was issued (the active filter
263
+ // contributes a predicate even when no other filters are set).
264
+ expect(db._whereSnapshots.length).toBe(1);
265
+ expect(db._whereSnapshots[0]).toBeDefined();
266
+ });
267
+
268
+ test("ISO-formats suppressedAt in the returned DTO", async () => {
269
+ const suppressedAt = new Date("2026-05-01T00:00:00.000Z");
270
+ const db = createMockDb({
271
+ storedRow: {
272
+ // No `id` field: getAnomalies scopes by systemId (+ the suppression
273
+ // predicate), not by row id, so the mock only enforces systemId here.
274
+ systemId: "sys-1",
275
+ startedAt: new Date("2026-04-01T00:00:00.000Z"),
276
+ confirmedAt: null,
277
+ recoveredAt: null,
278
+ suppressedAt,
279
+ },
280
+ });
281
+ const service = new AnomalyService(db as never);
282
+
283
+ const [row] = await service.getAnomalies({
284
+ systemId: "sys-1",
285
+ suppression: "suppressed",
286
+ });
287
+
288
+ expect(row.suppressedAt).toBe(suppressedAt.toISOString());
289
+ });
290
+ });
package/tsconfig.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "src"
5
5
  ],
6
6
  "references": [
7
+ {
8
+ "path": "../ai-backend"
9
+ },
7
10
  {
8
11
  "path": "../anomaly-common"
9
12
  },