@oneuptime/common 14.0.2 → 14.0.3

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 (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,1284 @@
1
+ import Semaphore, {
2
+ SemaphoreMutex,
3
+ } from "../../../Server/Infrastructure/Semaphore";
4
+ import AlertFeedService from "../../../Server/Services/AlertFeedService";
5
+ import AlertService from "../../../Server/Services/AlertService";
6
+ import AutoRemediationRuleEngineService from "../../../Server/Services/AutoRemediationRuleEngineService";
7
+ import CustomFieldMappingService from "../../../Server/Services/CustomFieldMappingService";
8
+ import IncidentAlertService, {
9
+ LinkAlertsToIncidentResult,
10
+ } from "../../../Server/Services/IncidentAlertService";
11
+ import IncidentFeedService from "../../../Server/Services/IncidentFeedService";
12
+ import IncidentGroupingEngineService from "../../../Server/Services/IncidentGroupingEngineService";
13
+ import IncidentLabelRuleEngineService from "../../../Server/Services/IncidentLabelRuleEngineService";
14
+ import IncidentMeasurementValueService from "../../../Server/Services/IncidentMeasurementValueService";
15
+ import IncidentOnCallRuleEngineService from "../../../Server/Services/IncidentOnCallRuleEngineService";
16
+ import IncidentOwnerRuleEngineService from "../../../Server/Services/IncidentOwnerRuleEngineService";
17
+ import IncidentPrivacyRuleEngineService from "../../../Server/Services/IncidentPrivacyRuleEngineService";
18
+ import IncidentService from "../../../Server/Services/IncidentService";
19
+ import IncidentSlaService from "../../../Server/Services/IncidentSlaService";
20
+ import IncidentStateService from "../../../Server/Services/IncidentStateService";
21
+ import IncidentStateTimelineService from "../../../Server/Services/IncidentStateTimelineService";
22
+ import ProjectService from "../../../Server/Services/ProjectService";
23
+ import RunbookRuleEngineService from "../../../Server/Services/RunbookRuleEngineService";
24
+ import UserService from "../../../Server/Services/UserService";
25
+ import { OnCreate } from "../../../Server/Types/Database/Hooks";
26
+ import AIIncidentInvestigationRunner from "../../../Server/Utils/AI/SRE/IncidentInvestigationRunner";
27
+ import ProjectScopedReferenceValidator from "../../../Server/Utils/Database/ProjectScopedReferenceValidator";
28
+ import logger from "../../../Server/Utils/Logger";
29
+ import ProductAnalytics from "../../../Server/Utils/ProductAnalytics";
30
+ import SloRecordReferenceValidator from "../../../Server/Utils/Slo/SloRecordReferenceValidator";
31
+ import Alert from "../../../Models/DatabaseModels/Alert";
32
+ import { AlertFeedEventType } from "../../../Models/DatabaseModels/AlertFeed";
33
+ import Incident from "../../../Models/DatabaseModels/Incident";
34
+ import IncidentAlert from "../../../Models/DatabaseModels/IncidentAlert";
35
+ import { IncidentFeedEventType } from "../../../Models/DatabaseModels/IncidentFeed";
36
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
37
+ import IncidentStateTimeline from "../../../Models/DatabaseModels/IncidentStateTimeline";
38
+ import URL from "../../../Types/API/URL";
39
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
40
+ import OneUptimeDate from "../../../Types/Date";
41
+ import { INCIDENT_ALERT_IDS_TO_LINK_KEY } from "../../../Types/Incident/IncidentAlertLink";
42
+ import { JSONObject } from "../../../Types/JSON";
43
+ import ObjectID from "../../../Types/ObjectID";
44
+ import PositiveNumber from "../../../Types/PositiveNumber";
45
+ import Permission, {
46
+ UserTenantAccessPermission,
47
+ } from "../../../Types/Permission";
48
+ import UserType from "../../../Types/UserType";
49
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
50
+ import { FindOperator } from "typeorm";
51
+
52
+ /*
53
+ * Declaring an incident from alerts, and carrying incident state changes over
54
+ * to linked alerts, at the two places they hook into existing services:
55
+ *
56
+ * - IncidentService.onBeforeCreate checks miscDataProps.alertIdsToLink BEFORE
57
+ * the incident number is taken, so a bad id neither burns a number nor
58
+ * leaves an incident behind; onCreateSuccess links the alerts once the
59
+ * incident exists, and a failing link never fails the incident;
60
+ * - IncidentStateTimelineService.onCreateSuccess hands the incident's new
61
+ * CURRENT state to the linked-alert cascade, after its mutex is released,
62
+ * and never waits on it or fails because of it.
63
+ *
64
+ * Everything around those hooks is stubbed; the cascade and the link helpers
65
+ * have their own tests.
66
+ */
67
+
68
+ const PROJECT_ID: ObjectID = new ObjectID(
69
+ "0194c3a9-0000-4000-8000-000000000001",
70
+ );
71
+ const INCIDENT_ID: ObjectID = new ObjectID(
72
+ "0194c3a9-0000-4000-8000-0000000000a1",
73
+ );
74
+ const ALERT_ID: string = "0194c3a9-0000-4000-8000-0000000000b1";
75
+ const ALERT_ID_2: string = "0194c3a9-0000-4000-8000-0000000000b2";
76
+ const FOREIGN_ALERT_ID: string = "0194c3a9-0000-4000-8000-0000000000bf";
77
+ const USER_ID: ObjectID = new ObjectID("0194c3a9-0000-4000-8000-0000000000c1");
78
+ const OTHER_USER_ID: ObjectID = new ObjectID(
79
+ "0194c3a9-0000-4000-8000-0000000000c2",
80
+ );
81
+ const CREATED_STATE_ID: string = "0194c3a9-0000-4000-8000-0000000000d1";
82
+ const ACKNOWLEDGED_STATE_ID: string = "0194c3a9-0000-4000-8000-0000000000d2";
83
+ const SEVERITY_ID: ObjectID = new ObjectID(
84
+ "0194c3a9-0000-4000-8000-0000000000e1",
85
+ );
86
+
87
+ type HookFunction = (...args: Array<unknown>) => Promise<unknown>;
88
+
89
+ function callHook<T>(
90
+ service: unknown,
91
+ name: string,
92
+ ...args: Array<unknown>
93
+ ): Promise<T> {
94
+ const hooks: Record<string, HookFunction> = service as Record<
95
+ string,
96
+ HookFunction
97
+ >;
98
+ return hooks[name]!.apply(service, args) as Promise<T>;
99
+ }
100
+
101
+ function userProps(permission: Permission): DatabaseCommonInteractionProps {
102
+ const tenantPermission: UserTenantAccessPermission = {
103
+ projectId: PROJECT_ID,
104
+ _type: "UserTenantAccessPermission",
105
+ permissions: [
106
+ {
107
+ _type: "UserPermission",
108
+ permission: permission,
109
+ labelIds: [],
110
+ isBlockPermission: false,
111
+ },
112
+ ],
113
+ };
114
+
115
+ return {
116
+ tenantId: PROJECT_ID,
117
+ userId: USER_ID,
118
+ userType: UserType.User,
119
+ userTenantAccessPermission: {
120
+ [PROJECT_ID.toString()]: tenantPermission,
121
+ },
122
+ };
123
+ }
124
+
125
+ // An API key: a project tenant and no user.
126
+ function apiKeyProps(): DatabaseCommonInteractionProps {
127
+ const props: DatabaseCommonInteractionProps = userProps(
128
+ Permission.ProjectMember,
129
+ );
130
+ delete props.userId;
131
+ props.userType = UserType.API;
132
+ return props;
133
+ }
134
+
135
+ async function settle(): Promise<void> {
136
+ // Let fire-and-forget chains run to completion while the stubs are live.
137
+ for (let i: number = 0; i < 5; i++) {
138
+ await new Promise<void>((resolve: () => void) => {
139
+ setTimeout(resolve, 0);
140
+ });
141
+ }
142
+ }
143
+
144
+ let errorLog: jest.SpyInstance;
145
+
146
+ beforeEach(() => {
147
+ errorLog = jest.spyOn(logger, "error").mockImplementation((() => {
148
+ // quiet
149
+ }) as never);
150
+ });
151
+
152
+ afterEach(async () => {
153
+ await settle();
154
+ jest.restoreAllMocks();
155
+ });
156
+
157
+ describe("IncidentService.onBeforeCreate with alerts to link", () => {
158
+ let counter: jest.SpyInstance;
159
+ let validate: jest.SpyInstance;
160
+
161
+ beforeEach(() => {
162
+ const createdState: IncidentState = new IncidentState();
163
+ createdState._id = CREATED_STATE_ID;
164
+
165
+ jest
166
+ .spyOn(IncidentStateService, "findOneBy")
167
+ .mockResolvedValue(createdState as never);
168
+ jest
169
+ .spyOn(
170
+ ProjectScopedReferenceValidator,
171
+ "validateReferencesBelongToProject",
172
+ )
173
+ .mockResolvedValue(undefined as never);
174
+ jest
175
+ .spyOn(
176
+ SloRecordReferenceValidator,
177
+ "validateServiceLevelObjectivesBelongToProject",
178
+ )
179
+ .mockResolvedValue(undefined as never);
180
+ jest
181
+ .spyOn(CustomFieldMappingService, "applyMappingsToCreate")
182
+ .mockResolvedValue(undefined as never);
183
+ jest
184
+ .spyOn(UserService, "getUserMarkdownString")
185
+ .mockResolvedValue("**A responder**" as never);
186
+
187
+ counter = jest
188
+ .spyOn(ProjectService, "incrementAndGetIncidentCounter")
189
+ .mockResolvedValue({ counter: 17, prefix: "INC-" } as never);
190
+
191
+ // The project's alerts: ALERT_ID and ALERT_ID_2. FOREIGN_ALERT_ID is not.
192
+ jest.spyOn(AlertService, "findBy").mockImplementation((async (args: {
193
+ query: { _id: unknown };
194
+ }): Promise<Array<Alert>> => {
195
+ const ids: Array<string> = Object.values(
196
+ (args.query._id as FindOperator<unknown>).objectLiteralParameters || {},
197
+ )[0] as Array<string>;
198
+
199
+ return ids
200
+ .filter((id: string) => {
201
+ return id !== FOREIGN_ALERT_ID;
202
+ })
203
+ .map((id: string) => {
204
+ const alert: Alert = new Alert();
205
+ alert._id = id;
206
+ return alert;
207
+ });
208
+ }) as never);
209
+
210
+ validate = jest.spyOn(
211
+ IncidentAlertService,
212
+ "validateAlertIdsForNewIncident",
213
+ );
214
+ });
215
+
216
+ function buildIncident(): Incident {
217
+ const incident: Incident = new Incident();
218
+ incident.projectId = PROJECT_ID;
219
+ incident.title = "Checkout is failing";
220
+ incident.incidentSeverityId = SEVERITY_ID;
221
+ return incident;
222
+ }
223
+
224
+ function onBeforeCreate(
225
+ miscDataProps: JSONObject | undefined,
226
+ props: DatabaseCommonInteractionProps = userProps(Permission.ProjectMember),
227
+ ): Promise<OnCreate<Incident>> {
228
+ return callHook<OnCreate<Incident>>(IncidentService, "onBeforeCreate", {
229
+ data: buildIncident(),
230
+ miscDataProps: miscDataProps,
231
+ props: props,
232
+ });
233
+ }
234
+
235
+ test("without miscDataProps nothing is validated or carried forward", async () => {
236
+ const result: OnCreate<Incident> = await onBeforeCreate(undefined);
237
+
238
+ expect(validate).not.toHaveBeenCalled();
239
+ expect(result.carryForward).toBeNull();
240
+ expect(counter).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ test("miscDataProps with other keys (owners) leave the alerts alone", async () => {
244
+ const result: OnCreate<Incident> = await onBeforeCreate({
245
+ ownerUsers: [USER_ID.toString()],
246
+ });
247
+
248
+ expect(validate).not.toHaveBeenCalled();
249
+ expect(result.carryForward).toBeNull();
250
+ });
251
+
252
+ test("a null list is treated as no list", async () => {
253
+ const result: OnCreate<Incident> = await onBeforeCreate({
254
+ [INCIDENT_ALERT_IDS_TO_LINK_KEY]: null,
255
+ });
256
+
257
+ expect(validate).not.toHaveBeenCalled();
258
+ expect(result.carryForward).toBeNull();
259
+ });
260
+
261
+ test.each([
262
+ ["not a list", ALERT_ID, "must be an array of alert ids"],
263
+ [
264
+ "a list holding something other than an id",
265
+ [ALERT_ID, 7],
266
+ "must only contain alert ids",
267
+ ],
268
+ [
269
+ "a list holding a malformed id",
270
+ ["not-a-uuid"],
271
+ "must only contain alert ids",
272
+ ],
273
+ ["an empty list", [], "Please select at least one alert"],
274
+ [
275
+ "more than 50 alerts",
276
+ Array.from({ length: 51 }, () => {
277
+ return ObjectID.generate().toString();
278
+ }),
279
+ "You can link at most 50 alerts",
280
+ ],
281
+ [
282
+ "an alert from another project",
283
+ [ALERT_ID, FOREIGN_ALERT_ID],
284
+ "do not exist in this project, or you do not have access to them",
285
+ ],
286
+ ])(
287
+ "%s is refused before an incident number is taken",
288
+ async (_label: string, alertIds: unknown, message: string) => {
289
+ await expect(
290
+ onBeforeCreate({ [INCIDENT_ALERT_IDS_TO_LINK_KEY]: alertIds as never }),
291
+ ).rejects.toThrow(message);
292
+
293
+ expect(counter).not.toHaveBeenCalled();
294
+ },
295
+ );
296
+
297
+ test("a caller who may not link alerts is refused before an incident number is taken", async () => {
298
+ await expect(
299
+ onBeforeCreate(
300
+ { [INCIDENT_ALERT_IDS_TO_LINK_KEY]: [ALERT_ID] },
301
+ userProps(Permission.IncidentViewer),
302
+ ),
303
+ ).rejects.toThrow(
304
+ "You do not have permission to link alerts to incidents in this project.",
305
+ );
306
+
307
+ expect(counter).not.toHaveBeenCalled();
308
+ });
309
+
310
+ test("valid alert ids are checked as the caller, then carried to onCreateSuccess", async () => {
311
+ const props: DatabaseCommonInteractionProps = userProps(
312
+ Permission.ProjectMember,
313
+ );
314
+
315
+ const result: OnCreate<Incident> = await onBeforeCreate(
316
+ {
317
+ [INCIDENT_ALERT_IDS_TO_LINK_KEY]: [
318
+ ALERT_ID,
319
+ ALERT_ID_2,
320
+ ALERT_ID.toUpperCase(),
321
+ ],
322
+ },
323
+ props,
324
+ );
325
+
326
+ expect(validate).toHaveBeenCalledTimes(1);
327
+
328
+ const args: {
329
+ projectId: ObjectID;
330
+ alertIds: unknown;
331
+ props: DatabaseCommonInteractionProps;
332
+ } = validate.mock.calls[0]![0];
333
+
334
+ expect(args.projectId.toString()).toBe(PROJECT_ID.toString());
335
+ expect(args.props).toBe(props);
336
+
337
+ // Validated before the number was taken.
338
+ expect(validate.mock.invocationCallOrder[0]!).toBeLessThan(
339
+ counter.mock.invocationCallOrder[0]!,
340
+ );
341
+
342
+ const carried: { alertIdsToLink: Array<ObjectID> } =
343
+ result.carryForward as { alertIdsToLink: Array<ObjectID> };
344
+
345
+ expect(carried.alertIdsToLink.map(String)).toEqual([ALERT_ID, ALERT_ID_2]);
346
+ expect(result.createBy.data.incidentNumber).toBe(17);
347
+ });
348
+
349
+ test("a root caller (API automation run as root) is validated too", async () => {
350
+ const result: OnCreate<Incident> = await onBeforeCreate(
351
+ { [INCIDENT_ALERT_IDS_TO_LINK_KEY]: [ALERT_ID] },
352
+ { isRoot: true },
353
+ );
354
+
355
+ expect(
356
+ (result.carryForward as { alertIdsToLink: Array<ObjectID> })
357
+ .alertIdsToLink,
358
+ ).toHaveLength(1);
359
+ });
360
+ });
361
+
362
+ describe("IncidentService.onCreateSuccess links the alerts it was declared with", () => {
363
+ let link: jest.SpyInstance;
364
+ let summary: jest.SpyInstance;
365
+ let copyOwners: jest.SpyInstance;
366
+ const chain: Record<string, jest.SpyInstance> = {};
367
+
368
+ beforeEach(() => {
369
+ jest.spyOn(ProductAnalytics, "captureForUser").mockImplementation((() => {
370
+ // no analytics in tests
371
+ }) as never);
372
+
373
+ const reRead: Incident = new Incident();
374
+ reRead._id = INCIDENT_ID.toString();
375
+ reRead.projectId = PROJECT_ID;
376
+ jest
377
+ .spyOn(IncidentService, "findOneById")
378
+ .mockResolvedValue(reRead as never);
379
+
380
+ // The un-awaited side-effect chain.
381
+ const service: Record<string, unknown> =
382
+ IncidentService as unknown as Record<string, unknown>;
383
+ for (const method of [
384
+ "handleIncidentWorkspaceOperationsAsync",
385
+ "createIncidentFeedAsync",
386
+ "handleIncidentStateChangeAsync",
387
+ "disableActiveMonitoringIfManualIncident",
388
+ "refreshReminderSchedule",
389
+ ]) {
390
+ chain[method] = jest
391
+ .spyOn(service as Record<string, () => Promise<void>>, method)
392
+ .mockResolvedValue(undefined as never);
393
+ }
394
+
395
+ jest
396
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
397
+ .mockResolvedValue(false as never);
398
+ jest
399
+ .spyOn(IncidentOwnerRuleEngineService, "applyRulesToIncident")
400
+ .mockResolvedValue(undefined as never);
401
+ jest
402
+ .spyOn(IncidentLabelRuleEngineService, "applyRulesToIncident")
403
+ .mockResolvedValue(undefined as never);
404
+ jest
405
+ .spyOn(IncidentOnCallRuleEngineService, "applyRulesToIncident")
406
+ .mockResolvedValue(undefined as never);
407
+ jest
408
+ .spyOn(RunbookRuleEngineService, "applyRulesToIncident")
409
+ .mockResolvedValue(undefined as never);
410
+ jest
411
+ .spyOn(IncidentGroupingEngineService, "processIncident")
412
+ .mockResolvedValue({} as never);
413
+ jest
414
+ .spyOn(IncidentSlaService, "createSlaForIncident")
415
+ .mockResolvedValue(undefined as never);
416
+ jest
417
+ .spyOn(AIIncidentInvestigationRunner, "investigateNewIncident")
418
+ .mockResolvedValue(false as never);
419
+ jest
420
+ .spyOn(AutoRemediationRuleEngineService, "applyRulesToIncident")
421
+ .mockResolvedValue(undefined as never);
422
+
423
+ link = jest
424
+ .spyOn(IncidentAlertService, "linkAlertsToIncident")
425
+ .mockResolvedValue({
426
+ linkedAlertIds: [],
427
+ alreadyLinkedAlertIds: [],
428
+ failed: [],
429
+ } as never);
430
+ summary = jest
431
+ .spyOn(IncidentAlertService, "createDeclaredFromAlertsFeedItem")
432
+ .mockResolvedValue(undefined as never);
433
+ copyOwners = jest
434
+ .spyOn(IncidentAlertService, "copyAlertOwnersToIncident")
435
+ .mockResolvedValue({ userIds: [], teamIds: [] } as never);
436
+ });
437
+
438
+ function createdIncident(
439
+ createdByUserId?: ObjectID,
440
+ isPrivate: boolean = false,
441
+ ): Incident {
442
+ const incident: Incident = new Incident();
443
+ incident._id = INCIDENT_ID.toString();
444
+ incident.projectId = PROJECT_ID;
445
+ incident.title = "Checkout is failing";
446
+ incident.declaredAt = OneUptimeDate.getCurrentDate();
447
+ incident.isPrivate = isPrivate;
448
+ if (createdByUserId) {
449
+ incident.createdByUserId = createdByUserId;
450
+ }
451
+ return incident;
452
+ }
453
+
454
+ function onCreateSuccess(
455
+ carryForward: unknown,
456
+ createdItem: Incident,
457
+ props: DatabaseCommonInteractionProps = { isRoot: true },
458
+ ): Promise<Incident> {
459
+ return callHook<Incident>(
460
+ IncidentService,
461
+ "onCreateSuccess",
462
+ {
463
+ createBy: { data: createdItem, props: props },
464
+ carryForward: carryForward,
465
+ },
466
+ createdItem,
467
+ );
468
+ }
469
+
470
+ test("links every validated alert as root, recording the declaring user", async () => {
471
+ const alertIds: Array<ObjectID> = [
472
+ new ObjectID(ALERT_ID),
473
+ new ObjectID(ALERT_ID_2),
474
+ ];
475
+ const incident: Incident = createdIncident(USER_ID);
476
+
477
+ await expect(
478
+ onCreateSuccess({ alertIdsToLink: alertIds }, incident),
479
+ ).resolves.toBe(incident);
480
+
481
+ expect(link).toHaveBeenCalledTimes(1);
482
+ expect(link).toHaveBeenCalledWith({
483
+ projectId: PROJECT_ID,
484
+ incidentId: incident.id,
485
+ alertIds: alertIds,
486
+ createdByUserId: USER_ID,
487
+ declaredWithIncident: true,
488
+ props: { isRoot: true },
489
+ });
490
+ });
491
+
492
+ test("a user who declares the incident is recorded as the one who linked the alerts", async () => {
493
+ await onCreateSuccess(
494
+ { alertIdsToLink: [new ObjectID(ALERT_ID)] },
495
+ createdIncident(),
496
+ { userId: OTHER_USER_ID, tenantId: PROJECT_ID },
497
+ );
498
+
499
+ expect(link.mock.calls[0]![0].createdByUserId).toBe(OTHER_USER_ID);
500
+ });
501
+
502
+ test("a user caller is recorded as themselves, whatever the incident's createdByUserId says", async () => {
503
+ await onCreateSuccess(
504
+ { alertIdsToLink: [new ObjectID(ALERT_ID)] },
505
+ createdIncident(USER_ID),
506
+ { userId: OTHER_USER_ID, tenantId: PROJECT_ID },
507
+ );
508
+
509
+ expect(link.mock.calls[0]![0].createdByUserId).toBe(OTHER_USER_ID);
510
+ });
511
+
512
+ test("an API key cannot name somebody else as the one who linked the alerts", async () => {
513
+ // The key sent data.createdByUserId naming another user.
514
+ await onCreateSuccess(
515
+ { alertIdsToLink: [new ObjectID(ALERT_ID)] },
516
+ createdIncident(OTHER_USER_ID),
517
+ apiKeyProps(),
518
+ );
519
+
520
+ expect(link).toHaveBeenCalledTimes(1);
521
+ expect(link.mock.calls[0]![0].createdByUserId).toBeUndefined();
522
+ // The links are still written as root, and still declared.
523
+ expect(link.mock.calls[0]![0].props).toEqual({ isRoot: true });
524
+ expect(link.mock.calls[0]![0].declaredWithIncident).toBe(true);
525
+ });
526
+
527
+ test("the links exist by the time the incident is returned", async () => {
528
+ let linked: boolean = false;
529
+
530
+ link.mockImplementation((async (): Promise<LinkAlertsToIncidentResult> => {
531
+ await new Promise<void>((resolve: () => void) => {
532
+ setTimeout(resolve, 5);
533
+ });
534
+ linked = true;
535
+ return { linkedAlertIds: [], alreadyLinkedAlertIds: [], failed: [] };
536
+ }) as never);
537
+
538
+ await onCreateSuccess(
539
+ { alertIdsToLink: [new ObjectID(ALERT_ID)] },
540
+ createdIncident(),
541
+ );
542
+
543
+ expect(linked).toBe(true);
544
+ });
545
+
546
+ test("a link that throws never fails the incident", async () => {
547
+ link.mockRejectedValue(new Error("database unavailable") as never);
548
+ const incident: Incident = createdIncident();
549
+
550
+ await expect(
551
+ onCreateSuccess({ alertIdsToLink: [new ObjectID(ALERT_ID)] }, incident),
552
+ ).resolves.toBe(incident);
553
+
554
+ expect(errorLog).toHaveBeenCalledWith(
555
+ expect.stringContaining("Linking the alerts an incident was declared"),
556
+ expect.anything(),
557
+ );
558
+ });
559
+
560
+ test("alerts that could not be linked are logged, and the incident is still returned", async () => {
561
+ link.mockResolvedValue({
562
+ linkedAlertIds: [new ObjectID(ALERT_ID)],
563
+ alreadyLinkedAlertIds: [],
564
+ failed: [{ alertId: new ObjectID(ALERT_ID_2), message: "gone" }],
565
+ } as never);
566
+ const incident: Incident = createdIncident();
567
+
568
+ await expect(
569
+ onCreateSuccess(
570
+ {
571
+ alertIdsToLink: [new ObjectID(ALERT_ID), new ObjectID(ALERT_ID_2)],
572
+ },
573
+ incident,
574
+ ),
575
+ ).resolves.toBe(incident);
576
+
577
+ expect(errorLog).toHaveBeenCalledWith(
578
+ expect.stringContaining("1 of 2 alerts could not be linked"),
579
+ expect.anything(),
580
+ );
581
+ });
582
+
583
+ test.each([
584
+ ["no carry-forward", null],
585
+ ["an undefined carry-forward", undefined],
586
+ ["an empty list", { alertIdsToLink: [] }],
587
+ ])(
588
+ "an incident declared with %s links nothing",
589
+ async (_label: string, carryForward: unknown) => {
590
+ await onCreateSuccess(carryForward, createdIncident(USER_ID, true));
591
+ await settle();
592
+
593
+ expect(link).not.toHaveBeenCalled();
594
+ expect(summary).not.toHaveBeenCalled();
595
+ expect(copyOwners).not.toHaveBeenCalled();
596
+ },
597
+ );
598
+
599
+ describe("one announcement instead of one per alert", () => {
600
+ const alertIds: Array<ObjectID> = [
601
+ new ObjectID(ALERT_ID),
602
+ new ObjectID(ALERT_ID_2),
603
+ ];
604
+
605
+ function order(name: string): number {
606
+ return chain[name]!.mock.invocationCallOrder[0]!;
607
+ }
608
+
609
+ test("the alerts are announced once, after Incident Created and the workspace channels", async () => {
610
+ await onCreateSuccess(
611
+ { alertIdsToLink: alertIds },
612
+ createdIncident(USER_ID),
613
+ );
614
+ await settle();
615
+
616
+ expect(summary).toHaveBeenCalledTimes(1);
617
+ expect(summary).toHaveBeenCalledWith({
618
+ projectId: PROJECT_ID,
619
+ incidentId: INCIDENT_ID,
620
+ alertIds: alertIds,
621
+ actorUserId: USER_ID,
622
+ });
623
+
624
+ const summaryOrder: number = summary.mock.invocationCallOrder[0]!;
625
+
626
+ expect(order("handleIncidentWorkspaceOperationsAsync")).toBeLessThan(
627
+ summaryOrder,
628
+ );
629
+ expect(order("createIncidentFeedAsync")).toBeLessThan(summaryOrder);
630
+ // ...and it is the very next step, ahead of the rest of the chain.
631
+ expect(summaryOrder).toBeLessThan(
632
+ order("handleIncidentStateChangeAsync"),
633
+ );
634
+ });
635
+
636
+ test("the links themselves are written without incident-side entries", async () => {
637
+ await onCreateSuccess(
638
+ { alertIdsToLink: alertIds },
639
+ createdIncident(USER_ID),
640
+ );
641
+
642
+ expect(link.mock.calls[0]![0].declaredWithIncident).toBe(true);
643
+ });
644
+
645
+ test("a user's announcement is theirs, an API key's names nobody", async () => {
646
+ await onCreateSuccess(
647
+ { alertIdsToLink: alertIds },
648
+ createdIncident(USER_ID),
649
+ { userId: OTHER_USER_ID, tenantId: PROJECT_ID },
650
+ );
651
+ await settle();
652
+
653
+ expect(summary.mock.calls[0]![0].actorUserId).toBe(OTHER_USER_ID);
654
+
655
+ summary.mockClear();
656
+
657
+ await onCreateSuccess(
658
+ { alertIdsToLink: alertIds },
659
+ createdIncident(OTHER_USER_ID),
660
+ apiKeyProps(),
661
+ );
662
+ await settle();
663
+
664
+ expect(summary).toHaveBeenCalledTimes(1);
665
+ expect(summary.mock.calls[0]![0].actorUserId).toBeUndefined();
666
+ });
667
+
668
+ test("it does not wait for the links", async () => {
669
+ let finishLinking: () => void = (): void => {
670
+ // replaced once linking starts
671
+ };
672
+
673
+ link.mockImplementation(
674
+ (async (): Promise<LinkAlertsToIncidentResult> => {
675
+ await new Promise<void>((resolve: () => void) => {
676
+ finishLinking = resolve;
677
+ });
678
+ return { linkedAlertIds: [], alreadyLinkedAlertIds: [], failed: [] };
679
+ }) as never,
680
+ );
681
+
682
+ const created: Promise<Incident> = onCreateSuccess(
683
+ { alertIdsToLink: alertIds },
684
+ createdIncident(USER_ID),
685
+ );
686
+ await settle();
687
+
688
+ // Still linking, already announced.
689
+ expect(summary).toHaveBeenCalledTimes(1);
690
+
691
+ finishLinking();
692
+ await created;
693
+ });
694
+
695
+ test("an announcement that fails is logged, and the rest of the chain carries on", async () => {
696
+ summary.mockRejectedValue(new Error("slack is down") as never);
697
+
698
+ const incident: Incident = createdIncident(USER_ID);
699
+
700
+ await expect(
701
+ onCreateSuccess({ alertIdsToLink: alertIds }, incident),
702
+ ).resolves.toBe(incident);
703
+ await settle();
704
+
705
+ expect(errorLog).toHaveBeenCalledWith(
706
+ expect.stringContaining(
707
+ "Announcing the alerts an incident was declared from failed",
708
+ ),
709
+ expect.anything(),
710
+ );
711
+ expect(chain["handleIncidentStateChangeAsync"]).toHaveBeenCalledTimes(1);
712
+ });
713
+
714
+ test("a failed Incident Created entry does not stop the announcement", async () => {
715
+ chain["createIncidentFeedAsync"]!.mockRejectedValue(
716
+ new Error("feed failed") as never,
717
+ );
718
+
719
+ await onCreateSuccess(
720
+ { alertIdsToLink: alertIds },
721
+ createdIncident(USER_ID),
722
+ );
723
+ await settle();
724
+
725
+ expect(summary).toHaveBeenCalledTimes(1);
726
+ });
727
+ });
728
+
729
+ describe("a private incident takes on the alerts' owners", () => {
730
+ const alertIds: Array<ObjectID> = [
731
+ new ObjectID(ALERT_ID),
732
+ new ObjectID(ALERT_ID_2),
733
+ ];
734
+
735
+ function order(name: string): number {
736
+ return chain[name]!.mock.invocationCallOrder[0]!;
737
+ }
738
+
739
+ test("the owners are added once the incident's channels exist, after Incident Created and the announcement", async () => {
740
+ await onCreateSuccess(
741
+ { alertIdsToLink: alertIds },
742
+ createdIncident(USER_ID, true),
743
+ userProps(Permission.ProjectMember),
744
+ );
745
+ await settle();
746
+
747
+ expect(copyOwners).toHaveBeenCalledTimes(1);
748
+ expect(copyOwners).toHaveBeenCalledWith({
749
+ projectId: PROJECT_ID,
750
+ incidentId: INCIDENT_ID,
751
+ alertIds: alertIds,
752
+ });
753
+
754
+ /*
755
+ * The owners' own hooks invite them to the incident's Slack / Teams
756
+ * channels, so the copy must come after the step that records them.
757
+ */
758
+ const copyOrder: number = copyOwners.mock.invocationCallOrder[0]!;
759
+
760
+ expect(order("handleIncidentWorkspaceOperationsAsync")).toBeLessThan(
761
+ copyOrder,
762
+ );
763
+ expect(order("createIncidentFeedAsync")).toBeLessThan(copyOrder);
764
+ expect(summary.mock.invocationCallOrder[0]!).toBeLessThan(copyOrder);
765
+ expect(copyOrder).toBeLessThan(order("handleIncidentStateChangeAsync"));
766
+ });
767
+
768
+ test("an incident a privacy rule makes private takes on the owners too", async () => {
769
+ jest
770
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
771
+ .mockImplementation((async (incident: Incident): Promise<boolean> => {
772
+ incident.isPrivate = true;
773
+ return true;
774
+ }) as never);
775
+
776
+ await onCreateSuccess(
777
+ { alertIdsToLink: alertIds },
778
+ createdIncident(USER_ID, false),
779
+ );
780
+ await settle();
781
+
782
+ expect(copyOwners).toHaveBeenCalledTimes(1);
783
+ });
784
+
785
+ test("a public incident keeps its owners as they are", async () => {
786
+ await onCreateSuccess(
787
+ { alertIdsToLink: alertIds },
788
+ createdIncident(USER_ID, false),
789
+ );
790
+ await settle();
791
+
792
+ expect(copyOwners).not.toHaveBeenCalled();
793
+ });
794
+
795
+ test("an incident declared without alerts never copies owners", async () => {
796
+ await onCreateSuccess(null, createdIncident(USER_ID, true));
797
+ await settle();
798
+
799
+ expect(copyOwners).not.toHaveBeenCalled();
800
+ });
801
+
802
+ test("the owners are added even when linking fails", async () => {
803
+ link.mockRejectedValue(new Error("database unavailable") as never);
804
+
805
+ await onCreateSuccess(
806
+ { alertIdsToLink: alertIds },
807
+ createdIncident(USER_ID, true),
808
+ );
809
+ await settle();
810
+
811
+ expect(copyOwners).toHaveBeenCalledTimes(1);
812
+ });
813
+
814
+ test("a failure adding them is logged, and the rest of the chain carries on", async () => {
815
+ copyOwners.mockRejectedValue(new Error("owners failed") as never);
816
+ const incident: Incident = createdIncident(USER_ID, true);
817
+
818
+ await expect(
819
+ onCreateSuccess({ alertIdsToLink: alertIds }, incident),
820
+ ).resolves.toBe(incident);
821
+ await settle();
822
+
823
+ expect(errorLog).toHaveBeenCalledWith(
824
+ expect.stringContaining(
825
+ "Adding the owners of the alerts a private incident was declared from failed",
826
+ ),
827
+ expect.anything(),
828
+ );
829
+ expect(chain["handleIncidentStateChangeAsync"]).toHaveBeenCalledTimes(1);
830
+ });
831
+ });
832
+
833
+ describe("linking waits for the privacy rules", () => {
834
+ test("the links are written only once the privacy rules have run", async () => {
835
+ let rulesApplied: boolean = false;
836
+ let rulesAppliedWhenLinking: boolean | null = null;
837
+
838
+ jest
839
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
840
+ .mockImplementation((async (incident: Incident): Promise<boolean> => {
841
+ await new Promise<void>((resolve: () => void) => {
842
+ setTimeout(resolve, 5);
843
+ });
844
+ incident.isPrivate = true;
845
+ rulesApplied = true;
846
+ return true;
847
+ }) as never);
848
+
849
+ link.mockImplementation(
850
+ (async (): Promise<LinkAlertsToIncidentResult> => {
851
+ rulesAppliedWhenLinking = rulesApplied;
852
+ return { linkedAlertIds: [], alreadyLinkedAlertIds: [], failed: [] };
853
+ }) as never,
854
+ );
855
+
856
+ await onCreateSuccess(
857
+ { alertIdsToLink: [new ObjectID(ALERT_ID)] },
858
+ createdIncident(USER_ID),
859
+ );
860
+
861
+ expect(link).toHaveBeenCalledTimes(1);
862
+ expect(rulesAppliedWhenLinking).toBe(true);
863
+ });
864
+
865
+ test("an incident declared without alerts does not wait for the privacy rules", async () => {
866
+ let finishRules: () => void = (): void => {
867
+ // replaced once the rules start
868
+ };
869
+
870
+ jest
871
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
872
+ .mockImplementation((async (): Promise<boolean> => {
873
+ await new Promise<void>((resolve: () => void) => {
874
+ finishRules = resolve;
875
+ });
876
+ return false;
877
+ }) as never);
878
+
879
+ const incident: Incident = createdIncident(USER_ID);
880
+
881
+ // Resolves while the rules are still running.
882
+ await expect(onCreateSuccess(null, incident)).resolves.toBe(incident);
883
+
884
+ finishRules();
885
+ });
886
+ });
887
+ });
888
+
889
+ describe("declaring an incident from alerts, through the real link service", () => {
890
+ let incidentFeed: jest.SpyInstance;
891
+ let alertFeed: jest.SpyInstance;
892
+ let save: jest.Mock;
893
+
894
+ const ALERT_TITLES: Record<string, string> = {
895
+ [ALERT_ID]: "Checkout p95 latency is high",
896
+ [ALERT_ID_2]: "Payroll DB credentials exposed",
897
+ };
898
+
899
+ function alertRow(id: string, number: number, isPrivate: boolean): Alert {
900
+ const alert: Alert = new Alert();
901
+ alert._id = id;
902
+ alert.alertNumber = number;
903
+ alert.title = ALERT_TITLES[id]!;
904
+ alert.isPrivate = isPrivate;
905
+ return alert;
906
+ }
907
+
908
+ beforeEach(() => {
909
+ jest.spyOn(ProductAnalytics, "captureForUser").mockImplementation((() => {
910
+ // no analytics in tests
911
+ }) as never);
912
+
913
+ const incident: Incident = new Incident();
914
+ incident._id = INCIDENT_ID.toString();
915
+ incident.projectId = PROJECT_ID;
916
+ incident.incidentNumber = 17;
917
+ incident.incidentNumberWithPrefix = "INC-17";
918
+ incident.title = "Checkout is failing";
919
+ jest
920
+ .spyOn(IncidentService, "findOneById")
921
+ .mockResolvedValue(incident as never);
922
+
923
+ const service: Record<string, unknown> =
924
+ IncidentService as unknown as Record<string, unknown>;
925
+ for (const method of [
926
+ "handleIncidentWorkspaceOperationsAsync",
927
+ "createIncidentFeedAsync",
928
+ "handleIncidentStateChangeAsync",
929
+ "disableActiveMonitoringIfManualIncident",
930
+ "refreshReminderSchedule",
931
+ ]) {
932
+ jest
933
+ .spyOn(service as Record<string, () => Promise<void>>, method)
934
+ .mockResolvedValue(undefined as never);
935
+ }
936
+
937
+ jest
938
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
939
+ .mockResolvedValue(false as never);
940
+ jest
941
+ .spyOn(IncidentOwnerRuleEngineService, "applyRulesToIncident")
942
+ .mockResolvedValue(undefined as never);
943
+ jest
944
+ .spyOn(IncidentLabelRuleEngineService, "applyRulesToIncident")
945
+ .mockResolvedValue(undefined as never);
946
+ jest
947
+ .spyOn(IncidentOnCallRuleEngineService, "applyRulesToIncident")
948
+ .mockResolvedValue(undefined as never);
949
+ jest
950
+ .spyOn(RunbookRuleEngineService, "applyRulesToIncident")
951
+ .mockResolvedValue(undefined as never);
952
+ jest
953
+ .spyOn(IncidentGroupingEngineService, "processIncident")
954
+ .mockResolvedValue({} as never);
955
+ jest
956
+ .spyOn(IncidentSlaService, "createSlaForIncident")
957
+ .mockResolvedValue(undefined as never);
958
+ jest
959
+ .spyOn(AIIncidentInvestigationRunner, "investigateNewIncident")
960
+ .mockResolvedValue(false as never);
961
+ jest
962
+ .spyOn(AutoRemediationRuleEngineService, "applyRulesToIncident")
963
+ .mockResolvedValue(undefined as never);
964
+
965
+ // The link table, without a database.
966
+ save = jest.fn(async (row: IncidentAlert): Promise<IncidentAlert> => {
967
+ row._id = ObjectID.generate().toString();
968
+ return row;
969
+ });
970
+ jest
971
+ .spyOn(IncidentAlertService, "getRepository")
972
+ .mockReturnValue({ save: save } as never);
973
+ jest
974
+ .spyOn(IncidentAlertService, "countBy")
975
+ .mockResolvedValue(new PositiveNumber(0) as never);
976
+ jest
977
+ .spyOn(
978
+ ProjectScopedReferenceValidator,
979
+ "validateReferencesBelongToProject",
980
+ )
981
+ .mockResolvedValue(undefined as never);
982
+ jest
983
+ .spyOn(IncidentAlertService, "onTriggerWorkflow")
984
+ .mockResolvedValue(undefined as never);
985
+ jest
986
+ .spyOn(IncidentAlertService, "onTriggerRealtime")
987
+ .mockResolvedValue(undefined as never);
988
+ jest
989
+ .spyOn(IncidentAlertService, "syncAlertWithLinkedIncidentState")
990
+ .mockResolvedValue(undefined as never);
991
+
992
+ // The alerts: #3 is public, #4 is private.
993
+ jest.spyOn(AlertService, "findOneById").mockImplementation((async (args: {
994
+ id: ObjectID;
995
+ }): Promise<Alert> => {
996
+ return args.id.toString() === ALERT_ID
997
+ ? alertRow(ALERT_ID, 3, false)
998
+ : alertRow(ALERT_ID_2, 4, true);
999
+ }) as never);
1000
+ jest
1001
+ .spyOn(AlertService, "findBy")
1002
+ .mockResolvedValue([
1003
+ alertRow(ALERT_ID_2, 4, true),
1004
+ alertRow(ALERT_ID, 3, false),
1005
+ ] as never);
1006
+ jest
1007
+ .spyOn(AlertService, "getAlertLinkInDashboard")
1008
+ .mockImplementation((async (
1009
+ _projectId: ObjectID,
1010
+ alertId: ObjectID,
1011
+ ): Promise<URL> => {
1012
+ return URL.fromString(
1013
+ `https://oneuptime.example/alerts/${alertId.toString()}`,
1014
+ );
1015
+ }) as never);
1016
+ jest
1017
+ .spyOn(IncidentService, "getIncidentLinkInDashboard")
1018
+ .mockResolvedValue(
1019
+ URL.fromString("https://oneuptime.example/incident") as never,
1020
+ );
1021
+
1022
+ incidentFeed = jest
1023
+ .spyOn(IncidentFeedService, "createIncidentFeedItem")
1024
+ .mockResolvedValue(undefined as never);
1025
+ alertFeed = jest
1026
+ .spyOn(AlertFeedService, "createAlertFeedItem")
1027
+ .mockResolvedValue(undefined as never);
1028
+ });
1029
+
1030
+ function declare(props: DatabaseCommonInteractionProps): Promise<Incident> {
1031
+ const incident: Incident = new Incident();
1032
+ incident._id = INCIDENT_ID.toString();
1033
+ incident.projectId = PROJECT_ID;
1034
+ incident.title = "Checkout is failing";
1035
+ incident.createdByUserId = USER_ID;
1036
+ incident.declaredAt = OneUptimeDate.getCurrentDate();
1037
+
1038
+ return callHook<Incident>(
1039
+ IncidentService,
1040
+ "onCreateSuccess",
1041
+ {
1042
+ createBy: { data: incident, props: props },
1043
+ carryForward: {
1044
+ alertIdsToLink: [new ObjectID(ALERT_ID), new ObjectID(ALERT_ID_2)],
1045
+ },
1046
+ },
1047
+ incident,
1048
+ );
1049
+ }
1050
+
1051
+ test("every alert is linked, and the incident gets exactly one entry and one post", async () => {
1052
+ await declare(userProps(Permission.ProjectMember));
1053
+
1054
+ // Both links were written before the incident was returned.
1055
+ expect(save).toHaveBeenCalledTimes(2);
1056
+ expect(
1057
+ save.mock.calls.map((call: Array<IncidentAlert>) => {
1058
+ return call[0]!.createdByUserId?.toString();
1059
+ }),
1060
+ ).toEqual([USER_ID.toString(), USER_ID.toString()]);
1061
+
1062
+ await settle();
1063
+
1064
+ // No per-link "Alert Linked" entry or post: just the one summary.
1065
+ expect(incidentFeed).toHaveBeenCalledTimes(1);
1066
+
1067
+ const entry: Record<string, unknown> = incidentFeed.mock.calls[0]![0];
1068
+
1069
+ expect(entry["incidentFeedEventType"]).toBe(
1070
+ IncidentFeedEventType.AlertLinked,
1071
+ );
1072
+ expect(entry["workspaceNotification"]).toEqual({
1073
+ sendWorkspaceNotification: true,
1074
+ notifyUserId: USER_ID,
1075
+ });
1076
+ expect(entry["feedInfoInMarkdown"]).toBe(
1077
+ [
1078
+ "🔗 Declared from 2 alerts:",
1079
+ "",
1080
+ `- **[Alert #3](https://oneuptime.example/alerts/${ALERT_ID})**: Checkout p95 latency is high`,
1081
+ `- **[Alert #4](https://oneuptime.example/alerts/${ALERT_ID_2})** (private alert)`,
1082
+ ].join("\n"),
1083
+ );
1084
+
1085
+ // Each alert still records that it was linked, without a post.
1086
+ expect(alertFeed).toHaveBeenCalledTimes(2);
1087
+ for (const call of alertFeed.mock.calls) {
1088
+ const alertEntry: Record<string, unknown> = call[0];
1089
+ expect(alertEntry["alertFeedEventType"]).toBe(
1090
+ AlertFeedEventType.LinkedToIncident,
1091
+ );
1092
+ expect(alertEntry["workspaceNotification"]).toBeUndefined();
1093
+ expect(String(alertEntry["userId"])).toBe(USER_ID.toString());
1094
+ }
1095
+ });
1096
+
1097
+ test("links made one by one outside a declaration each get their own incident entry", async () => {
1098
+ await IncidentAlertService.linkAlertsToIncident({
1099
+ projectId: PROJECT_ID,
1100
+ incidentId: INCIDENT_ID,
1101
+ alertIds: [new ObjectID(ALERT_ID), new ObjectID(ALERT_ID_2)],
1102
+ createdByUserId: USER_ID,
1103
+ props: { isRoot: true },
1104
+ });
1105
+
1106
+ expect(incidentFeed).toHaveBeenCalledTimes(2);
1107
+ expect(alertFeed).toHaveBeenCalledTimes(2);
1108
+ expect(
1109
+ incidentFeed.mock.calls.map((call: Array<Record<string, unknown>>) => {
1110
+ return (call[0]!["workspaceNotification"] as Record<string, unknown>)[
1111
+ "sendWorkspaceNotification"
1112
+ ];
1113
+ }),
1114
+ ).toEqual([true, true]);
1115
+ });
1116
+ });
1117
+
1118
+ describe("IncidentStateTimelineService.onCreateSuccess hands the new state to the cascade", () => {
1119
+ let cascade: jest.SpyInstance;
1120
+ let release: jest.SpyInstance;
1121
+ let updateIncident: jest.SpyInstance;
1122
+ let stateFeed: jest.SpyInstance;
1123
+
1124
+ beforeEach(() => {
1125
+ const state: IncidentState = new IncidentState();
1126
+ state._id = ACKNOWLEDGED_STATE_ID;
1127
+ state.name = "Acknowledged";
1128
+ state.isAcknowledgedState = true;
1129
+ state.isResolvedState = false;
1130
+
1131
+ jest
1132
+ .spyOn(IncidentStateService, "findOneBy")
1133
+ .mockResolvedValue(state as never);
1134
+ updateIncident = jest
1135
+ .spyOn(IncidentService, "updateOneBy")
1136
+ .mockResolvedValue(1 as never);
1137
+ jest.spyOn(IncidentService, "getIncidentNumber").mockResolvedValue({
1138
+ number: 17,
1139
+ numberWithPrefix: "INC-17",
1140
+ } as never);
1141
+ jest
1142
+ .spyOn(IncidentService, "getIncidentLinkInDashboard")
1143
+ .mockResolvedValue(
1144
+ URL.fromString("https://oneuptime.example/incident") as never,
1145
+ );
1146
+ jest
1147
+ .spyOn(IncidentService, "refreshIncidentMetrics")
1148
+ .mockResolvedValue(undefined as never);
1149
+ jest
1150
+ .spyOn(IncidentMeasurementValueService, "recomputeForIncident")
1151
+ .mockResolvedValue(undefined as never);
1152
+ stateFeed = jest
1153
+ .spyOn(IncidentFeedService, "createIncidentFeedItem")
1154
+ .mockResolvedValue(undefined as never);
1155
+
1156
+ const service: Record<string, () => Promise<unknown>> =
1157
+ IncidentStateTimelineService as unknown as Record<
1158
+ string,
1159
+ () => Promise<unknown>
1160
+ >;
1161
+ jest
1162
+ .spyOn(service, "trackSlaStateChange")
1163
+ .mockResolvedValue(undefined as never);
1164
+ jest
1165
+ .spyOn(service, "isLastIncidentState")
1166
+ .mockResolvedValue(false as never);
1167
+
1168
+ release = jest
1169
+ .spyOn(Semaphore, "release")
1170
+ .mockResolvedValue(undefined as never);
1171
+
1172
+ cascade = jest
1173
+ .spyOn(IncidentAlertService, "cascadeIncidentStateToLinkedAlerts")
1174
+ .mockResolvedValue(undefined as never);
1175
+ });
1176
+
1177
+ function timeline(endsAt?: Date): IncidentStateTimeline {
1178
+ const row: IncidentStateTimeline = new IncidentStateTimeline();
1179
+ row._id = ObjectID.generate().toString();
1180
+ row.projectId = PROJECT_ID;
1181
+ row.incidentId = INCIDENT_ID;
1182
+ row.incidentStateId = new ObjectID(ACKNOWLEDGED_STATE_ID);
1183
+ row.startsAt = OneUptimeDate.getCurrentDate();
1184
+ if (endsAt) {
1185
+ row.endsAt = endsAt;
1186
+ }
1187
+ return row;
1188
+ }
1189
+
1190
+ function onCreateSuccess(
1191
+ createdItem: IncidentStateTimeline,
1192
+ mutex: SemaphoreMutex | null = null,
1193
+ ): Promise<IncidentStateTimeline> {
1194
+ return callHook<IncidentStateTimeline>(
1195
+ IncidentStateTimelineService,
1196
+ "onCreateSuccess",
1197
+ {
1198
+ createBy: { data: createdItem, props: { isRoot: true } },
1199
+ carryForward: {
1200
+ statusTimelineBeforeThisStatus: null,
1201
+ statusTimelineAfterThisStatus: null,
1202
+ publicNote: undefined,
1203
+ mutex: mutex,
1204
+ },
1205
+ },
1206
+ createdItem,
1207
+ );
1208
+ }
1209
+
1210
+ test("the incident's new current state is cascaded to its linked alerts", async () => {
1211
+ await onCreateSuccess(timeline());
1212
+
1213
+ expect(cascade).toHaveBeenCalledTimes(1);
1214
+
1215
+ const args: {
1216
+ projectId: ObjectID;
1217
+ incidentId: ObjectID;
1218
+ incidentStateId: ObjectID;
1219
+ } = cascade.mock.calls[0]![0];
1220
+
1221
+ expect(args.projectId).toBe(PROJECT_ID);
1222
+ expect(args.incidentId).toBe(INCIDENT_ID);
1223
+ expect(args.incidentStateId.toString()).toBe(ACKNOWLEDGED_STATE_ID);
1224
+ });
1225
+
1226
+ test("the cascade starts only after the incident's mutex is released", async () => {
1227
+ const mutex: SemaphoreMutex = {
1228
+ key: INCIDENT_ID.toString(),
1229
+ } as unknown as SemaphoreMutex;
1230
+
1231
+ await onCreateSuccess(timeline(), mutex);
1232
+
1233
+ expect(release).toHaveBeenCalledWith(mutex);
1234
+ expect(release.mock.invocationCallOrder[0]!).toBeLessThan(
1235
+ cascade.mock.invocationCallOrder[0]!,
1236
+ );
1237
+ });
1238
+
1239
+ test("a back-dated row (one with an end) is not the current state and cascades nothing", async () => {
1240
+ await onCreateSuccess(timeline(OneUptimeDate.getCurrentDate()));
1241
+
1242
+ expect(cascade).not.toHaveBeenCalled();
1243
+ // ...and, as before, it does not become the incident's current state.
1244
+ expect(updateIncident).not.toHaveBeenCalled();
1245
+ });
1246
+
1247
+ test("the state change neither waits for nor fails with the cascade", async () => {
1248
+ let finished: boolean = false;
1249
+
1250
+ cascade.mockImplementation((async (): Promise<void> => {
1251
+ await new Promise<void>((resolve: () => void) => {
1252
+ setTimeout(resolve, 20);
1253
+ });
1254
+ finished = true;
1255
+ throw new Error("cascade failed");
1256
+ }) as never);
1257
+
1258
+ const row: IncidentStateTimeline = timeline();
1259
+
1260
+ await expect(onCreateSuccess(row)).resolves.toBe(row);
1261
+ expect(finished).toBe(false);
1262
+
1263
+ await new Promise<void>((resolve: () => void) => {
1264
+ setTimeout(resolve, 40);
1265
+ });
1266
+
1267
+ expect(finished).toBe(true);
1268
+ expect(errorLog).toHaveBeenCalledWith(
1269
+ expect.stringContaining("carrying the incident state over"),
1270
+ expect.anything(),
1271
+ );
1272
+ });
1273
+
1274
+ test("the rest of the hook is unchanged: the state is stored and announced", async () => {
1275
+ await onCreateSuccess(timeline());
1276
+
1277
+ expect(updateIncident).toHaveBeenCalledWith(
1278
+ expect.objectContaining({
1279
+ data: { currentIncidentStateId: new ObjectID(ACKNOWLEDGED_STATE_ID) },
1280
+ }),
1281
+ );
1282
+ expect(stateFeed).toHaveBeenCalledTimes(1);
1283
+ });
1284
+ });