@checkstack/anomaly-backend 1.1.9 → 1.2.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,33 @@
1
+ /**
2
+ * Contract test: every anomaly Versioned config that is stored and read back
3
+ * via the migration chain MUST have a COMPLETE, contiguous chain from version
4
+ * 1 to its current `version`. Pure STRUCTURAL check
5
+ * (`validateMigrationChainFromV1` — no `migrate()` is run), so it carries zero
6
+ * per-config upkeep: the day someone bumps a config's `version` without
7
+ * shipping a covering migration, `parse`/`parseRecord` would silently fail at
8
+ * runtime on a genuinely-v1 stored record — this test turns that into a CI
9
+ * failure instead. See the HTTP plugin's equivalent test for the full
10
+ * rationale.
11
+ *
12
+ * Covers the two module-level Versioned wrappers this package owns: the
13
+ * site-wide settings config and the assignment-level override config.
14
+ */
15
+ import { describe, expect, it } from "bun:test";
16
+ import { anomalySettingsConfig, anomalyAssignmentConfig } from "./config";
17
+
18
+ describe("anomaly config migration-chain contract", () => {
19
+ const configs = [
20
+ { name: "anomaly settings", config: anomalySettingsConfig },
21
+ { name: "anomaly assignment", config: anomalyAssignmentConfig },
22
+ ];
23
+
24
+ it("every registered Versioned config has a complete v1->version chain", () => {
25
+ for (const { name, config } of configs) {
26
+ const problem = config.validateMigrationChainFromV1();
27
+ expect(
28
+ problem,
29
+ `${name} config (version ${config.version}) has a broken migration chain: ${problem}`,
30
+ ).toBeUndefined();
31
+ }
32
+ });
33
+ });
package/src/plugin.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import { createBackendPlugin, coreServices, type SafeDatabase } from "@checkstack/backend-api";
2
+ import {
3
+ aiToolProjectionExtensionPoint,
4
+ deferredProjectionExecute,
5
+ } from "@checkstack/ai-backend";
2
6
  import { healthCheckHooks } from "@checkstack/healthcheck-backend";
3
7
  import { setupBaselineAnalyzerJob } from "./jobs/baseline-analyzer";
4
8
  import { processCheckCompleted } from "./detector";
@@ -13,6 +17,7 @@ import {
13
17
  anomalyAccessRules,
14
18
  anomalySystemSubscription,
15
19
  anomalyGroupSubscription,
20
+ pluginMetadata,
16
21
  } from "@checkstack/anomaly-common";
17
22
  import { specToRegistration } from "@checkstack/notification-common";
18
23
  import { HealthCheckApi } from "@checkstack/healthcheck-common";
@@ -46,6 +51,20 @@ export const plugin = createBackendPlugin({
46
51
  // Mutable ref populated during init(); the reconciler closure pulls
47
52
  // the service via the lazy accessor at sync time.
48
53
  let gitopsService: AnomalyService | undefined;
54
+ // Expose anomaly's own read-only AI projection so ai-backend never has
55
+ // to import @checkstack/anomaly-common. The projection re-uses the
56
+ // existing getAnomalies contract procedure (read-only, access-gated).
57
+ env.getExtensionPoint(aiToolProjectionExtensionPoint).expose({
58
+ procedure: anomalyContract.getAnomalies,
59
+ sourcePluginMetadata: pluginMetadata,
60
+ procedureKey: "getAnomalies",
61
+ name: "anomaly.explain",
62
+ description:
63
+ "List detected anomalies (statistical sigma/drift) for context. Read-only.",
64
+ effect: "read",
65
+ execute: deferredProjectionExecute,
66
+ });
67
+
49
68
  const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
50
69
  registerAnomalyGitOpsKinds({
51
70
  kindRegistry,
package/src/router.ts CHANGED
@@ -7,9 +7,7 @@ import {
7
7
  type Logger,
8
8
  type RealUser,
9
9
  type RpcContext,
10
- type VersionedRecord,
11
10
  } from "@checkstack/backend-api";
12
- import type { AnomalySettings } from "@checkstack/anomaly-common";
13
11
  import type { AnomalyRouterCache } from "./router-cache";
14
12
 
15
13
  export function createRouter(
@@ -57,15 +55,14 @@ export function createRouter(
57
55
  cache.invalidateAnomalies(),
58
56
  cache.invalidateBaselines(),
59
57
  ]);
60
- return result as VersionedRecord<AnomalySettings>;
58
+ return result;
61
59
  }
62
60
  ),
63
61
 
64
62
  getAnomalyAssignmentConfig: os.getAnomalyAssignmentConfig.handler(
65
63
  async ({ input }) => {
66
64
  const result = await service.getAnomalyAssignmentConfig(input.systemId, input.configurationId);
67
-
68
- return (result as VersionedRecord<Partial<AnomalySettings>>) ?? null;
65
+ return result ?? null;
69
66
  }
70
67
  ),
71
68
 
@@ -76,10 +73,32 @@ export function createRouter(
76
73
  cache.invalidateAnomalies(),
77
74
  cache.invalidateBaselines(),
78
75
  ]);
79
- return result as VersionedRecord<Partial<AnomalySettings>>;
76
+ return result;
80
77
  }
81
78
  ),
82
79
 
80
+ suppressAnomaly: os.suppressAnomaly.handler(
81
+ async ({ input }) => {
82
+ const success = await service.suppressAnomaly({
83
+ anomalyId: input.anomalyId,
84
+ systemId: input.systemId,
85
+ });
86
+ await cache.invalidateAnomalies();
87
+ return { success };
88
+ },
89
+ ),
90
+
91
+ unsuppressAnomaly: os.unsuppressAnomaly.handler(
92
+ async ({ input }) => {
93
+ const success = await service.unsuppressAnomaly({
94
+ anomalyId: input.anomalyId,
95
+ systemId: input.systemId,
96
+ });
97
+ await cache.invalidateAnomalies();
98
+ return { success };
99
+ },
100
+ ),
101
+
83
102
  listAnomalyNotificationMutes: os.listAnomalyNotificationMutes.handler(
84
103
  async ({ input, context }) => {
85
104
  const userId = (context.user as RealUser).id;
package/src/schema.ts CHANGED
@@ -52,6 +52,21 @@ export const anomalies = pgTable("anomalies", {
52
52
  startedAt: timestamp("started_at").defaultNow().notNull(),
53
53
  confirmedAt: timestamp("confirmed_at"),
54
54
  recoveredAt: timestamp("recovered_at"),
55
+ /**
56
+ * Global (per-row) suppression. We model suppression as a flag layered on top
57
+ * of `state` rather than a new `suppressed` enum value: the existing
58
+ * suspicious/anomaly/recovered state machine (in both the spike detector and
59
+ * the drift evaluator) stays intact, and un-suppressing simply reveals the
60
+ * underlying state again. A NULL `suppressedAt` means "not suppressed".
61
+ *
62
+ * Lives on the shared `anomalies` row (Postgres) so every horizontally-scaled
63
+ * pod reads the same suppressed/active set — see state-and-scale.md. The
64
+ * snapshot columns capture the value/baseline at suppression time so the
65
+ * inline detector can auto-unsuppress once the metric "changes again".
66
+ */
67
+ suppressedAt: timestamp("suppressed_at"),
68
+ suppressedValue: doublePrecision("suppressed_value"),
69
+ suppressedBaseline: doublePrecision("suppressed_baseline"),
55
70
  metadata: jsonb("metadata").$type<Record<string, unknown>>(),
56
71
  });
57
72
 
package/src/service.ts CHANGED
@@ -1,9 +1,16 @@
1
- import { eq, and, desc, inArray } from "drizzle-orm";
1
+ import { eq, and, desc, inArray, isNull, isNotNull } from "drizzle-orm";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
3
  import * as schema from "./schema";
4
- import { anomalySettingsConfig } from "./config";
4
+ import {
5
+ anomalySettingsConfig,
6
+ anomalyAssignmentConfig,
7
+ toVersionedRecord,
8
+ } from "./config";
5
9
  import type { VersionedRecord } from "@checkstack/backend-api";
6
- import type { AnomalySettings } from "@checkstack/anomaly-common";
10
+ import type {
11
+ AnomalySettings,
12
+ PartialAnomalySettings,
13
+ } from "@checkstack/anomaly-common";
7
14
 
8
15
  export class AnomalyService {
9
16
  constructor(private readonly db: SafeDatabase<typeof schema>) {}
@@ -13,6 +20,12 @@ export class AnomalyService {
13
20
  configurationId?: string;
14
21
  state?: schema.AnomalyState;
15
22
  kind?: schema.AnomalyKind;
23
+ /**
24
+ * Suppression filter. Defaults to "active": suppressed rows are excluded
25
+ * from the active view. Pass "suppressed" to list only suppressed rows, or
26
+ * "all" to ignore the suppression flag entirely.
27
+ */
28
+ suppression?: "active" | "suppressed" | "all";
16
29
  limit?: number;
17
30
  }) {
18
31
  const conditions = [];
@@ -32,6 +45,13 @@ export class AnomalyService {
32
45
  conditions.push(eq(schema.anomalies.kind, params.kind));
33
46
  }
34
47
 
48
+ const suppression = params.suppression ?? "active";
49
+ if (suppression === "active") {
50
+ conditions.push(isNull(schema.anomalies.suppressedAt));
51
+ } else if (suppression === "suppressed") {
52
+ conditions.push(isNotNull(schema.anomalies.suppressedAt));
53
+ }
54
+
35
55
  const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
36
56
 
37
57
  const results = await this.db
@@ -44,13 +64,112 @@ export class AnomalyService {
44
64
  return results.map((r) => ({
45
65
  ...r,
46
66
  startedAt: r.startedAt.toISOString(),
47
-
67
+
48
68
  confirmedAt: r.confirmedAt?.toISOString() ?? null,
49
-
69
+
50
70
  recoveredAt: r.recoveredAt?.toISOString() ?? null,
71
+
72
+ suppressedAt: r.suppressedAt?.toISOString() ?? null,
51
73
  }));
52
74
  }
53
75
 
76
+ /**
77
+ * Globally suppress a single anomaly row. Snapshots the current observed
78
+ * value and baseline so the inline detector can auto-unsuppress once the
79
+ * metric "changes again" (moves outside the relative reactivation band).
80
+ *
81
+ * The mutation is scoped by BOTH `anomalyId` and `systemId` — the access
82
+ * gate authorizes the caller on `systemId`, so we must verify the target row
83
+ * actually belongs to that system, otherwise a user with `feed.manage` on
84
+ * system A could suppress system B's anomaly by passing B's id (IDOR).
85
+ *
86
+ * Only confirmed (`state === "anomaly"`) rows can be suppressed: the
87
+ * auto-unsuppress re-evaluation lives in the confirmed-anomaly branch of the
88
+ * detector, and a suppressed suspicious row would otherwise still confirm and
89
+ * notify. Returns false if no matching confirmed row exists.
90
+ */
91
+ async suppressAnomaly({
92
+ anomalyId,
93
+ systemId,
94
+ }: {
95
+ anomalyId: string;
96
+ systemId: string;
97
+ }): Promise<boolean> {
98
+ const [existing] = await this.db
99
+ .select()
100
+ .from(schema.anomalies)
101
+ .where(
102
+ and(
103
+ eq(schema.anomalies.id, anomalyId),
104
+ eq(schema.anomalies.systemId, systemId),
105
+ ),
106
+ )
107
+ .limit(1);
108
+
109
+ if (!existing || existing.state !== "anomaly") return false;
110
+
111
+ const observedNumeric = Number(existing.observedValue);
112
+
113
+ await this.db
114
+ .update(schema.anomalies)
115
+ .set({
116
+ suppressedAt: new Date(),
117
+ suppressedValue: Number.isFinite(observedNumeric)
118
+ ? observedNumeric
119
+ : null,
120
+ suppressedBaseline: existing.baselineValue,
121
+ })
122
+ .where(
123
+ and(
124
+ eq(schema.anomalies.id, anomalyId),
125
+ eq(schema.anomalies.systemId, systemId),
126
+ ),
127
+ );
128
+
129
+ return true;
130
+ }
131
+
132
+ /**
133
+ * Clear suppression on a single anomaly row. Scoped by both `anomalyId` and
134
+ * `systemId` for the same IDOR reason as {@link suppressAnomaly}.
135
+ */
136
+ async unsuppressAnomaly({
137
+ anomalyId,
138
+ systemId,
139
+ }: {
140
+ anomalyId: string;
141
+ systemId: string;
142
+ }): Promise<boolean> {
143
+ const [existing] = await this.db
144
+ .select()
145
+ .from(schema.anomalies)
146
+ .where(
147
+ and(
148
+ eq(schema.anomalies.id, anomalyId),
149
+ eq(schema.anomalies.systemId, systemId),
150
+ ),
151
+ )
152
+ .limit(1);
153
+
154
+ if (!existing) return false;
155
+
156
+ await this.db
157
+ .update(schema.anomalies)
158
+ .set({
159
+ suppressedAt: null,
160
+ suppressedValue: null,
161
+ suppressedBaseline: null,
162
+ })
163
+ .where(
164
+ and(
165
+ eq(schema.anomalies.id, anomalyId),
166
+ eq(schema.anomalies.systemId, systemId),
167
+ ),
168
+ );
169
+
170
+ return true;
171
+ }
172
+
54
173
  async getAnomalyBaselines(params: {
55
174
  systemId: string;
56
175
  configurationId: string;
@@ -88,7 +207,10 @@ export class AnomalyService {
88
207
  });
89
208
  }
90
209
 
91
- return result.config as VersionedRecord<AnomalySettings>;
210
+ // Migrate-then-validate the stored record on read. `version: 1` with no
211
+ // migrations today, so this is behavior-preserving now and stays correct
212
+ // once a migration is added.
213
+ return anomalySettingsConfig.parseRecord(toVersionedRecord(result.config));
92
214
  }
93
215
 
94
216
  async updateAnomalyConfig(
@@ -97,7 +219,7 @@ export class AnomalyService {
97
219
  ) {
98
220
  const newConfigRecord = anomalySettingsConfig.create(configData);
99
221
 
100
- const [result] = await this.db
222
+ await this.db
101
223
  .insert(schema.anomalyConfigurations)
102
224
  .values({
103
225
  configurationId,
@@ -106,10 +228,11 @@ export class AnomalyService {
106
228
  .onConflictDoUpdate({
107
229
  target: [schema.anomalyConfigurations.configurationId],
108
230
  set: { config: newConfigRecord },
109
- })
110
- .returning();
231
+ });
111
232
 
112
- return result!.config;
233
+ // Return the validated record we just persisted (typed), rather than the
234
+ // untyped jsonb round-trip.
235
+ return newConfigRecord;
113
236
  }
114
237
 
115
238
  async getAnomalyAssignmentConfig(systemId: string, configurationId: string) {
@@ -123,22 +246,23 @@ export class AnomalyService {
123
246
  ),
124
247
  );
125
248
 
126
- return result
127
- ? (result.config as VersionedRecord<Partial<AnomalySettings>>)
128
- : undefined;
249
+ if (!result) return;
250
+
251
+ // Migrate-then-validate the stored override record on read (see
252
+ // getAnomalyConfig). `version: 1` no-migrations today.
253
+ return anomalyAssignmentConfig.parseRecord(
254
+ toVersionedRecord(result.config),
255
+ );
129
256
  }
130
257
 
131
258
  async updateAnomalyAssignmentConfig(
132
259
  systemId: string,
133
260
  configurationId: string,
134
- configData: Partial<AnomalySettings>,
261
+ configData: PartialAnomalySettings,
135
262
  ) {
136
- const newConfigRecord = {
137
- version: anomalySettingsConfig.version,
138
- data: configData,
139
- };
263
+ const newConfigRecord = anomalyAssignmentConfig.create(configData);
140
264
 
141
- const [result] = await this.db
265
+ await this.db
142
266
  .insert(schema.anomalyAssignments)
143
267
  .values({
144
268
  systemId,
@@ -151,10 +275,10 @@ export class AnomalyService {
151
275
  schema.anomalyAssignments.configurationId,
152
276
  ],
153
277
  set: { config: newConfigRecord },
154
- })
155
- .returning();
278
+ });
156
279
 
157
- return result.config;
280
+ // Return the validated record we just persisted (typed).
281
+ return newConfigRecord;
158
282
  }
159
283
 
160
284
  /**
@@ -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
  },