@oneuptime/common 12.0.16 → 12.0.17

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.
Files changed (22) hide show
  1. package/Models/DatabaseModels/DetectionRule.ts +81 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.ts +39 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +303 -46
  5. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +250 -0
  6. package/Tests/App/Dashboard/SecurityEventsMonitorStepForm.test.tsx +106 -0
  7. package/Tests/App/Dashboard/SecurityEventsMonitorsPage.test.tsx +185 -0
  8. package/Tests/App/Dashboard/SecurityEventsSetupGuide.test.tsx +200 -0
  9. package/Tests/Models/DetectionRuleCreateContract.test.ts +35 -0
  10. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +438 -0
  11. package/Types/SecurityEvent/DetectionFindingConstants.ts +24 -0
  12. package/build/dist/Models/DatabaseModels/DetectionRule.js +81 -0
  13. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  14. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js +24 -0
  15. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788600000000-AddDetectionRuleIncidentColumns.js.map +1 -0
  16. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  17. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  18. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +220 -24
  19. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  20. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +18 -0
  21. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -0
  22. package/package.json +1 -1
@@ -0,0 +1,250 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, render } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import { MemoryRouter } from "react-router-dom";
13
+
14
+ /*
15
+ * The Detection Rules page is one big ModelTable call, and everything this
16
+ * change added to it — the incident toggle, the two severity dropdowns
17
+ * with their showIf chains, the DB-default-mirroring initial values, and
18
+ * the "Create Monitor" row action — is configuration passed as props.
19
+ * Dropping any of it is type-safe and render-safe, so the props are the
20
+ * only place it can be pinned. The table itself is mocked to capture them;
21
+ * the showIf and onClick functions are then exercised directly.
22
+ */
23
+
24
+ type CapturedFormField = {
25
+ field: Record<string, boolean>;
26
+ title: string;
27
+ showIf?: ((model: Record<string, unknown>) => boolean) | undefined;
28
+ dropdownModal?: { type: unknown } | undefined;
29
+ };
30
+
31
+ type CapturedActionButton = {
32
+ title: string;
33
+ disabled?: boolean | undefined;
34
+ tooltip?: string | undefined;
35
+ onClick: (
36
+ item: Record<string, unknown>,
37
+ onCompleteAction: () => void,
38
+ onError: (error: Error) => void,
39
+ ) => void;
40
+ };
41
+
42
+ type CapturedTableProps = {
43
+ formFields?: Array<CapturedFormField>;
44
+ actionButtons?: Array<CapturedActionButton>;
45
+ createInitialValues?: Record<string, unknown>;
46
+ };
47
+
48
+ let capturedTableProps: CapturedTableProps | null = null;
49
+
50
+ jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: (props: CapturedTableProps) => {
54
+ capturedTableProps = props;
55
+ return null;
56
+ },
57
+ };
58
+ });
59
+
60
+ import DetectionRulesPage from "../../../../App/FeatureSet/Dashboard/src/Pages/SecurityEvents/DetectionRules";
61
+ import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
62
+ import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
63
+ import Project from "../../../Models/DatabaseModels/Project";
64
+ import ProjectUtil from "../../../UI/Utils/Project";
65
+ import Navigation from "../../../UI/Utils/Navigation";
66
+ import PermissionGate, {
67
+ PermissionGateResult,
68
+ } from "../../../UI/Utils/PermissionGate";
69
+ import ObjectID from "../../../Types/ObjectID";
70
+ import Route from "../../../Types/API/Route";
71
+
72
+ const PROJECT_ID: ObjectID = new ObjectID(
73
+ "11111111-1111-4111-8111-111111111111",
74
+ );
75
+
76
+ function fieldTitled(title: string): CapturedFormField {
77
+ const field: CapturedFormField | undefined =
78
+ capturedTableProps?.formFields?.find(
79
+ (formField: CapturedFormField): boolean => {
80
+ return formField.title === title;
81
+ },
82
+ );
83
+
84
+ expect(field).toBeDefined();
85
+
86
+ return field!;
87
+ }
88
+
89
+ function renderPage(): void {
90
+ const project: Project = new Project();
91
+ project.id = PROJECT_ID;
92
+
93
+ render(
94
+ <MemoryRouter>
95
+ <DetectionRulesPage
96
+ pageRoute={new Route("/dashboard/security-events/detection-rules")}
97
+ currentProject={project}
98
+ hasPaymentMethod={true}
99
+ />
100
+ </MemoryRouter>,
101
+ );
102
+
103
+ expect(capturedTableProps).not.toBeNull();
104
+ }
105
+
106
+ function gateMonitorCreate(result: PermissionGateResult): void {
107
+ jest.spyOn(PermissionGate, "check").mockReturnValue(result);
108
+ }
109
+
110
+ describe("Detection Rules page", () => {
111
+ beforeEach(() => {
112
+ capturedTableProps = null;
113
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
114
+ gateMonitorCreate({ isAllowed: true });
115
+ });
116
+
117
+ afterEach(() => {
118
+ cleanup();
119
+ jest.restoreAllMocks();
120
+ });
121
+
122
+ describe("incident opt-in fields", () => {
123
+ test("the incident toggle exists on the evaluation step", () => {
124
+ renderPage();
125
+ fieldTitled("Create Incident on Match");
126
+ });
127
+
128
+ test("the incident severity dropdown shows only when the toggle is on", () => {
129
+ renderPage();
130
+ const severityField: CapturedFormField = fieldTitled("Incident Severity");
131
+
132
+ expect(severityField.showIf).toBeDefined();
133
+ expect(severityField.showIf!({ shouldCreateIncident: true })).toBe(true);
134
+ expect(severityField.showIf!({ shouldCreateIncident: false })).toBe(
135
+ false,
136
+ );
137
+ // undefined must read as off — the column defaults to false.
138
+ expect(severityField.showIf!({})).toBe(false);
139
+ });
140
+
141
+ test("the incident severity dropdown selects from IncidentSeverity, not AlertSeverity", () => {
142
+ renderPage();
143
+ const severityField: CapturedFormField = fieldTitled("Incident Severity");
144
+
145
+ expect(severityField.dropdownModal?.type).toBe(IncidentSeverity);
146
+ expect(severityField.field).toEqual({ incidentSeverity: true });
147
+ });
148
+
149
+ test("the alert severity dropdown chains on the alert toggle the same way", () => {
150
+ renderPage();
151
+ const severityField: CapturedFormField = fieldTitled("Alert Severity");
152
+
153
+ expect(severityField.dropdownModal?.type).toBe(AlertSeverity);
154
+ expect(severityField.showIf!({ shouldCreateAlert: true })).toBe(true);
155
+ expect(severityField.showIf!({ shouldCreateAlert: false })).toBe(false);
156
+ });
157
+
158
+ test("initial values mirror the DB defaults, so the chained fields are honest on a fresh form", () => {
159
+ /*
160
+ * shouldCreateAlert defaults TRUE in the schema and
161
+ * shouldCreateIncident FALSE. Without these initial values a fresh
162
+ * create form shows the alert toggle apparently off (undefined)
163
+ * with its severity dropdown hidden — while saving would create an
164
+ * alerting rule.
165
+ */
166
+ renderPage();
167
+ expect(capturedTableProps?.createInitialValues).toMatchObject({
168
+ shouldCreateAlert: true,
169
+ shouldWriteDetectionFinding: true,
170
+ shouldCreateIncident: false,
171
+ });
172
+ });
173
+ });
174
+
175
+ describe("Create Monitor row action", () => {
176
+ test("navigates to monitor create carrying the rule id", () => {
177
+ renderPage();
178
+
179
+ const navigateSpy: ReturnType<typeof jest.spyOn> = jest
180
+ .spyOn(Navigation, "navigate")
181
+ .mockImplementation(() => {
182
+ return undefined;
183
+ });
184
+
185
+ const button: CapturedActionButton | undefined =
186
+ capturedTableProps?.actionButtons?.find(
187
+ (actionButton: CapturedActionButton): boolean => {
188
+ return actionButton.title === "Create Monitor";
189
+ },
190
+ );
191
+
192
+ expect(button).toBeDefined();
193
+
194
+ let completed: boolean = false;
195
+
196
+ button!.onClick(
197
+ { _id: "22222222-2222-4222-8222-222222222222" },
198
+ () => {
199
+ completed = true;
200
+ },
201
+ () => {
202
+ // no-op
203
+ },
204
+ );
205
+
206
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
207
+
208
+ const destination: string = String(navigateSpy.mock.calls[0]?.[0]);
209
+
210
+ expect(destination).toContain("/monitors/create");
211
+ expect(destination).toContain(
212
+ "detectionRuleId=22222222-2222-4222-8222-222222222222",
213
+ );
214
+
215
+ // A row button that never completes spins forever.
216
+ expect(completed).toBe(true);
217
+ });
218
+
219
+ test("is enabled with the explainer tooltip when monitor create is allowed", () => {
220
+ renderPage();
221
+
222
+ const button: CapturedActionButton | undefined =
223
+ capturedTableProps?.actionButtons?.[0];
224
+
225
+ expect(button?.disabled).toBe(false);
226
+ expect(button?.tooltip).toContain("Detection Findings");
227
+ });
228
+
229
+ test("is disabled with the gate's reason when monitor create is not allowed", () => {
230
+ /*
231
+ * Same contract as MonitorTable's create button (issue #3306): a
232
+ * member who cannot create monitors must not be walked into the
233
+ * wizard to be refused at submit — the button stays visible,
234
+ * disabled, and says which permission is missing.
235
+ */
236
+ gateMonitorCreate({
237
+ isAllowed: false,
238
+ disabledReason: "You need the Create Monitor permission.",
239
+ });
240
+
241
+ renderPage();
242
+
243
+ const button: CapturedActionButton | undefined =
244
+ capturedTableProps?.actionButtons?.[0];
245
+
246
+ expect(button?.disabled).toBe(true);
247
+ expect(button?.tooltip).toBe("You need the Create Monitor permission.");
248
+ });
249
+ });
250
+ });
@@ -0,0 +1,106 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import SecurityEventsMonitorStepForm from "../../../../App/FeatureSet/Dashboard/src/Components/Form/Monitor/SecurityEventsMonitor/SecurityEventsMonitorStepForm";
13
+ import MonitorStepSecurityEventsMonitor, {
14
+ MonitorStepSecurityEventsMonitorUtil,
15
+ } from "../../../Types/Monitor/MonitorStepSecurityEventsMonitor";
16
+ import AnalyticsModelAPI from "../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
17
+ import ProjectUtil from "../../../UI/Utils/Project";
18
+ import ObjectID from "../../../Types/ObjectID";
19
+
20
+ /*
21
+ * The step form's field gating IS the feature here: Event Class was moved
22
+ * out from behind the "Show Advanced Options" toggle so that watching
23
+ * Detection Findings — the single most useful class filter — no longer
24
+ * requires knowing the toggle exists. No other test renders this form
25
+ * (the view-model and type tests cover different layers), so without
26
+ * this one a refactor could quietly put the field back behind the toggle
27
+ * and nothing would fail.
28
+ */
29
+
30
+ const PROJECT_ID: ObjectID = new ObjectID(
31
+ "11111111-1111-4111-8111-111111111111",
32
+ );
33
+
34
+ function renderForm(config?: Partial<MonitorStepSecurityEventsMonitor>): void {
35
+ render(
36
+ <SecurityEventsMonitorStepForm
37
+ monitorStepSecurityEventsMonitor={{
38
+ ...MonitorStepSecurityEventsMonitorUtil.getDefault(),
39
+ ...(config || {}),
40
+ }}
41
+ onMonitorStepSecurityEventsMonitorChanged={() => {
42
+ // no-op
43
+ }}
44
+ telemetryServices={[]}
45
+ />,
46
+ );
47
+ }
48
+
49
+ describe("SecurityEventsMonitorStepForm field gating", () => {
50
+ beforeEach(() => {
51
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
52
+ // The live preview polls the event count; keep it quiet and offline.
53
+ jest.spyOn(AnalyticsModelAPI, "count").mockResolvedValue(0 as never);
54
+ });
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ jest.restoreAllMocks();
59
+ });
60
+
61
+ test("Event Class is visible without opening advanced options", async () => {
62
+ renderForm();
63
+
64
+ // findBy: lets the preview's initial async count resolve inside act.
65
+ expect(await screen.findByText("Event Class")).toBeInTheDocument();
66
+ expect(screen.getByText("Show Advanced Options")).toBeInTheDocument();
67
+ });
68
+
69
+ test("severity, service and attribute filters stay behind the toggle", async () => {
70
+ renderForm();
71
+
72
+ await screen.findByText("Event Class");
73
+
74
+ expect(screen.queryByText("Event Severity")).not.toBeInTheDocument();
75
+ expect(
76
+ screen.queryByText("Filter by Telemetry Service"),
77
+ ).not.toBeInTheDocument();
78
+ expect(screen.queryByText("Filter by Attributes")).not.toBeInTheDocument();
79
+
80
+ fireEvent.click(screen.getByText("Show Advanced Options"));
81
+
82
+ expect(screen.getByText("Event Severity")).toBeInTheDocument();
83
+ expect(screen.getByText("Filter by Telemetry Service")).toBeInTheDocument();
84
+ expect(screen.getByText("Filter by Attributes")).toBeInTheDocument();
85
+ });
86
+
87
+ test("a stored severity filter still opens the advanced section by itself", async () => {
88
+ /*
89
+ * Editing a monitor whose config uses an advanced filter must show
90
+ * that filter immediately — the auto-open behaviour classNames used
91
+ * to share before it stopped being advanced.
92
+ */
93
+ renderForm({ severityNames: ["High" as never] });
94
+
95
+ expect(await screen.findByText("Event Severity")).toBeInTheDocument();
96
+ expect(screen.getByText("Hide Advanced Options")).toBeInTheDocument();
97
+ });
98
+
99
+ test("a stored class filter alone no longer forces the advanced section open", async () => {
100
+ renderForm({ classNames: ["Detection Finding"] });
101
+
102
+ expect(await screen.findByText("Event Class")).toBeInTheDocument();
103
+ expect(screen.queryByText("Event Severity")).not.toBeInTheDocument();
104
+ expect(screen.getByText("Show Advanced Options")).toBeInTheDocument();
105
+ });
106
+ });
@@ -0,0 +1,185 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, render, screen } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import { MemoryRouter } from "react-router-dom";
13
+
14
+ /*
15
+ * The Security Events → Monitors tab is a thin composition over
16
+ * MonitorTable, and the two things that make it correct are both props:
17
+ * the base query that scopes the table to MonitorType.SecurityEvents, and
18
+ * the replacement create button that deep-links into the gated monitor
19
+ * create page with the type preselected. Neither failing looks wrong on
20
+ * screen — an unscoped table just shows more rows, and a plain create
21
+ * button just asks the user to pick the type again — so the captured
22
+ * props are what gets pinned.
23
+ */
24
+
25
+ type CapturedCardButton = {
26
+ title: string;
27
+ onClick: () => void;
28
+ };
29
+
30
+ type CapturedMonitorTableProps = {
31
+ query?: Record<string, unknown>;
32
+ disableCreate?: boolean;
33
+ cardButtons?: Array<CapturedCardButton>;
34
+ saveFilterProps?: { tableId?: string };
35
+ title?: string;
36
+ };
37
+
38
+ let capturedMonitorTableProps: CapturedMonitorTableProps | null = null;
39
+
40
+ jest.mock(
41
+ "../../../../App/FeatureSet/Dashboard/src/Components/Monitor/MonitorTable",
42
+ () => {
43
+ return {
44
+ __esModule: true,
45
+ default: (props: CapturedMonitorTableProps) => {
46
+ capturedMonitorTableProps = props;
47
+ return null;
48
+ },
49
+ };
50
+ },
51
+ );
52
+
53
+ import SecurityEventsMonitorsPage from "../../../../App/FeatureSet/Dashboard/src/Pages/SecurityEvents/Monitors";
54
+ import MonitorType from "../../../Types/Monitor/MonitorType";
55
+ import Project from "../../../Models/DatabaseModels/Project";
56
+ import Reseller from "../../../Models/DatabaseModels/Reseller";
57
+ import ProjectUtil from "../../../UI/Utils/Project";
58
+ import Navigation from "../../../UI/Utils/Navigation";
59
+ import PermissionGate from "../../../UI/Utils/PermissionGate";
60
+ import { CardButtonSchema } from "../../../UI/Components/Card/Card";
61
+ import ObjectID from "../../../Types/ObjectID";
62
+ import Route from "../../../Types/API/Route";
63
+
64
+ const PROJECT_ID: ObjectID = new ObjectID(
65
+ "11111111-1111-4111-8111-111111111111",
66
+ );
67
+
68
+ function renderPage(project: Project): void {
69
+ render(
70
+ <MemoryRouter>
71
+ <SecurityEventsMonitorsPage
72
+ pageRoute={new Route("/dashboard/security-events/monitors")}
73
+ currentProject={project}
74
+ hasPaymentMethod={true}
75
+ />
76
+ </MemoryRouter>,
77
+ );
78
+ }
79
+
80
+ function buildProject(options: { telemetryDisabled?: boolean } = {}): Project {
81
+ const project: Project = new Project();
82
+ project.id = PROJECT_ID;
83
+
84
+ if (options.telemetryDisabled) {
85
+ const reseller: Reseller = new Reseller();
86
+ reseller.enableTelemetryFeatures = false;
87
+ project.reseller = reseller;
88
+ }
89
+
90
+ return project;
91
+ }
92
+
93
+ describe("Security Events monitors page", () => {
94
+ beforeEach(() => {
95
+ capturedMonitorTableProps = null;
96
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
97
+ /*
98
+ * Pass the button through unchanged: what the PERMISSION gate does is
99
+ * PermissionGate's own test's problem; this page's contract is that
100
+ * the button it hands the gate deep-links correctly.
101
+ */
102
+ jest
103
+ .spyOn(PermissionGate, "gateCardButton")
104
+ .mockImplementation((button: CardButtonSchema) => {
105
+ return button;
106
+ });
107
+ });
108
+
109
+ afterEach(() => {
110
+ cleanup();
111
+ jest.restoreAllMocks();
112
+ });
113
+
114
+ test("scopes the table to Security Events monitors in this project", () => {
115
+ renderPage(buildProject());
116
+
117
+ expect(capturedMonitorTableProps).not.toBeNull();
118
+ expect(capturedMonitorTableProps?.query).toMatchObject({
119
+ monitorType: MonitorType.SecurityEvents,
120
+ });
121
+ expect(capturedMonitorTableProps?.query?.["projectId"]?.toString()).toBe(
122
+ PROJECT_ID.toString(),
123
+ );
124
+ });
125
+
126
+ test("keeps its filter state under its own table id", () => {
127
+ renderPage(buildProject());
128
+
129
+ expect(capturedMonitorTableProps?.saveFilterProps?.tableId).toBe(
130
+ "security-events-monitors-table",
131
+ );
132
+ });
133
+
134
+ test("replaces the built-in create with a type-preselecting deep link", () => {
135
+ renderPage(buildProject());
136
+
137
+ expect(capturedMonitorTableProps?.disableCreate).toBe(true);
138
+
139
+ const button: CapturedCardButton | undefined =
140
+ capturedMonitorTableProps?.cardButtons?.[0];
141
+
142
+ expect(button?.title).toBe("Create Security Events Monitor");
143
+
144
+ const navigateSpy: ReturnType<typeof jest.spyOn> = jest
145
+ .spyOn(Navigation, "navigate")
146
+ .mockImplementation(() => {
147
+ return undefined;
148
+ });
149
+
150
+ button!.onClick();
151
+
152
+ const destination: string = String(navigateSpy.mock.calls[0]?.[0]);
153
+
154
+ expect(destination).toContain("/monitors/create");
155
+ /*
156
+ * Encoded, not raw: the enum value carries a space, which Route's
157
+ * character validator rejects — this exact assertion is what caught
158
+ * the crash the first time.
159
+ */
160
+ expect(destination).toContain(
161
+ `monitorType=${encodeURIComponent(MonitorType.SecurityEvents)}`,
162
+ );
163
+ });
164
+
165
+ test("drops the create button entirely when the permission gate says no", () => {
166
+ jest
167
+ .spyOn(PermissionGate, "gateCardButton")
168
+ .mockImplementation((): CardButtonSchema | null => {
169
+ return null;
170
+ });
171
+
172
+ renderPage(buildProject());
173
+
174
+ expect(capturedMonitorTableProps?.cardButtons).toEqual([]);
175
+ });
176
+
177
+ test("shows the reseller telemetry gate instead of the table", () => {
178
+ renderPage(buildProject({ telemetryDisabled: true }));
179
+
180
+ expect(capturedMonitorTableProps).toBeNull();
181
+ expect(
182
+ screen.getByText(/did not include telemetry features/i),
183
+ ).toBeInTheDocument();
184
+ });
185
+ });