@oneuptime/common 14.0.5 → 14.0.7

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 (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -1,19 +1,51 @@
1
1
  import Entities from "../../../Models/DatabaseModels/Index";
2
+ import Incident from "../../../Models/DatabaseModels/Incident";
2
3
  import IncidentAlert from "../../../Models/DatabaseModels/IncidentAlert";
4
+ import { AlertFeedEventType } from "../../../Models/DatabaseModels/AlertFeed";
3
5
  import PostgresAppInstance from "../../../Server/Infrastructure/PostgresDatabase";
6
+ import Semaphore, {
7
+ SemaphoreMutex,
8
+ } from "../../../Server/Infrastructure/Semaphore";
4
9
  import AlertFeedService from "../../../Server/Services/AlertFeedService";
10
+ import AlertMeasurementValueService from "../../../Server/Services/AlertMeasurementValueService";
5
11
  import AlertService from "../../../Server/Services/AlertService";
12
+ import AlertStateTimelineService from "../../../Server/Services/AlertStateTimelineService";
13
+ import AutoRemediationRuleEngineService from "../../../Server/Services/AutoRemediationRuleEngineService";
14
+ import CustomFieldMappingService from "../../../Server/Services/CustomFieldMappingService";
6
15
  import IncidentAlertService, {
16
+ AcknowledgeDeclaredAlertsResult,
17
+ AlertsToAcknowledgeOnDeclare,
7
18
  LinkAlertsToIncidentResult,
8
19
  } from "../../../Server/Services/IncidentAlertService";
9
20
  import IncidentFeedService from "../../../Server/Services/IncidentFeedService";
21
+ import IncidentGroupingEngineService from "../../../Server/Services/IncidentGroupingEngineService";
22
+ import IncidentLabelRuleEngineService from "../../../Server/Services/IncidentLabelRuleEngineService";
23
+ import IncidentOnCallRuleEngineService from "../../../Server/Services/IncidentOnCallRuleEngineService";
24
+ import IncidentOwnerRuleEngineService from "../../../Server/Services/IncidentOwnerRuleEngineService";
25
+ import IncidentOwnerUserService from "../../../Server/Services/IncidentOwnerUserService";
26
+ import IncidentPrivacyRuleEngineService from "../../../Server/Services/IncidentPrivacyRuleEngineService";
10
27
  import IncidentService from "../../../Server/Services/IncidentService";
28
+ import IncidentSlaService from "../../../Server/Services/IncidentSlaService";
29
+ import RunbookRuleEngineService from "../../../Server/Services/RunbookRuleEngineService";
30
+ import WorkspaceNotificationRuleService from "../../../Server/Services/WorkspaceNotificationRuleService";
31
+ import AIIncidentInvestigationRunner from "../../../Server/Utils/AI/SRE/IncidentInvestigationRunner";
32
+ import AlertStateChangeAuthorization from "../../../Server/Utils/Alert/AlertStateChangeAuthorization";
11
33
  import PostgresErrorTranslator from "../../../Server/Utils/Database/PostgresErrorTranslator";
34
+ import ProductAnalytics from "../../../Server/Utils/ProductAnalytics";
12
35
  import URL from "../../../Types/API/URL";
13
36
  import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
14
- import { INCIDENT_ALERT_ALREADY_LINKED_MESSAGE } from "../../../Types/Incident/IncidentAlertLink";
37
+ import PermissionScope from "../../../Types/Database/AccessControl/PermissionScope";
38
+ import BadDataException from "../../../Types/Exception/BadDataException";
39
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
40
+ import {
41
+ INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY,
42
+ INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
43
+ INCIDENT_ALERT_IDS_TO_LINK_KEY,
44
+ } from "../../../Types/Incident/IncidentAlertLink";
45
+ import { JSONObject } from "../../../Types/JSON";
15
46
  import ObjectID from "../../../Types/ObjectID";
16
47
  import Permission, {
48
+ UserPermission,
17
49
  UserTenantAccessPermission,
18
50
  } from "../../../Types/Permission";
19
51
  import PositiveNumber from "../../../Types/PositiveNumber";
@@ -900,4 +932,1596 @@ describePostgres("IncidentAlert against a migrated Postgres", () => {
900
932
  expect(await linkCount()).toBe(0);
901
933
  });
902
934
  });
935
+
936
+ /*
937
+ * Declaring an incident from alerts and asking, in the same request, for
938
+ * the alerts to be acknowledged (miscDataProps.acknowledgeAlertsToLink):
939
+ * the real IncidentService.create, the real links, and the real
940
+ * acknowledgement - AlertService.changeAlertState writing an
941
+ * AlertStateTimeline row and moving the alert's current state - against
942
+ * the clones. This block adds clones of the tables that path reads and
943
+ * writes: the alert and incident states, the incident severities, the
944
+ * alert state timeline, and the label / monitor / SLO relations the new
945
+ * incident is re-read with (and the alerts' labels, which the permission
946
+ * check reads). They live in the same schema and are dropped with it.
947
+ *
948
+ * Stubbed, beyond the stubs above: the Redis locks (there is no Redis
949
+ * here), and what surrounds the declaration without being about the
950
+ * alerts' state - the un-awaited onCreateSuccess chain (workspace
951
+ * channels, feeds, rules, SLA, reminders, AI), metrics, custom field
952
+ * mappings, auto-ownership, workflows and realtime.
953
+ */
954
+ describe("declaring an incident from alerts and acknowledging them, through IncidentService", () => {
955
+ const DECLARE_TABLES: Array<string> = [
956
+ "AlertState",
957
+ "AlertStateTimeline",
958
+ "IncidentState",
959
+ "IncidentSeverity",
960
+ "Label",
961
+ "AlertLabel",
962
+ "IncidentLabel",
963
+ "Monitor",
964
+ "IncidentMonitor",
965
+ "ServiceLevelObjective",
966
+ "IncidentServiceLevelObjective",
967
+ ];
968
+
969
+ // The create does not wait for the acknowledgement: how long a test does.
970
+ const ACKNOWLEDGEMENT_TIMEOUT_MS: number = 15000;
971
+
972
+ const NO_ACKNOWLEDGED_STATE_MESSAGE: string =
973
+ "This project has no Acknowledged alert state, so the alerts cannot be acknowledged. Declare the incident without acknowledging them, or add an Acknowledged state in the alert settings.";
974
+
975
+ const NO_PERMISSION_TO_ACKNOWLEDGE_MESSAGE: string =
976
+ "You do not have permission to acknowledge one or more of these alerts. Declare the incident without acknowledging them, or ask a project admin for permission.";
977
+
978
+ const PRIVATE_INCIDENT_CAUSE: string =
979
+ "Acknowledged because a private incident was declared from this alert.";
980
+
981
+ const NOT_VISIBLE_ALERTS_MESSAGE: string =
982
+ "One or more of the selected alerts do not exist in this project, or you do not have access to them.";
983
+
984
+ type AcknowledgeArguments = Parameters<
985
+ typeof IncidentAlertService.acknowledgeAlertsDeclaredWithIncident
986
+ >[0];
987
+
988
+ type SyncArguments = Parameters<
989
+ typeof IncidentAlertService.syncAlertWithLinkedIncidentState
990
+ >[0];
991
+
992
+ type AlertFeedItem = Parameters<
993
+ typeof AlertFeedService.createAlertFeedItem
994
+ >[0];
995
+
996
+ type StateFlag =
997
+ | "isCreatedState"
998
+ | "isAcknowledgedState"
999
+ | "isResolvedState";
1000
+
1001
+ interface ProjectStates {
1002
+ created: ObjectID;
1003
+ acknowledged: ObjectID;
1004
+ resolved: ObjectID;
1005
+ }
1006
+
1007
+ interface TimelineRow {
1008
+ alertStateId: string;
1009
+ createdByUserId: string | null;
1010
+ rootCause: string | null;
1011
+ isOwnerNotified: boolean;
1012
+ startsAt: Date;
1013
+ endsAt: Date | null;
1014
+ }
1015
+
1016
+ interface IncidentRow {
1017
+ _id: string;
1018
+ incidentNumber: number | null;
1019
+ currentIncidentStateId: string;
1020
+ isPrivate: boolean;
1021
+ }
1022
+
1023
+ // What validateAcknowledgeAlertsForNewIncident settled, as strings.
1024
+ interface CheckedAcknowledgement {
1025
+ acknowledgedAlertStateId: string;
1026
+ alertIdsToAcknowledge: Array<string>;
1027
+ }
1028
+
1029
+ const ownSpies: Array<jest.SpyInstance> = [];
1030
+
1031
+ let alertFeed: jest.SpyInstance;
1032
+ let acknowledge: jest.SpyInstance;
1033
+ let linksWhenAcknowledging: Array<number> = [];
1034
+
1035
+ let alertStates: ProjectStates;
1036
+ let incidentStates: ProjectStates;
1037
+ let incidentSeverityId: ObjectID;
1038
+
1039
+ beforeAll(async () => {
1040
+ for (const table of DECLARE_TABLES) {
1041
+ await database.query(
1042
+ `CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`,
1043
+ );
1044
+ }
1045
+
1046
+ const own: (spy: jest.SpyInstance) => void = (
1047
+ spy: jest.SpyInstance,
1048
+ ): void => {
1049
+ ownSpies.push(spy);
1050
+ };
1051
+
1052
+ // No Redis here, and one request at a time.
1053
+ own(
1054
+ jest.spyOn(Semaphore, "lock").mockResolvedValue({} as SemaphoreMutex),
1055
+ );
1056
+ own(jest.spyOn(Semaphore, "release").mockResolvedValue(undefined));
1057
+
1058
+ own(
1059
+ jest
1060
+ .spyOn(ProductAnalytics, "captureForUser")
1061
+ .mockImplementation((): void => {
1062
+ // No analytics in tests.
1063
+ }),
1064
+ );
1065
+ own(
1066
+ jest
1067
+ .spyOn(CustomFieldMappingService, "applyMappingsToCreate")
1068
+ .mockResolvedValue(undefined),
1069
+ );
1070
+ own(
1071
+ jest
1072
+ .spyOn(CustomFieldMappingService, "applyMappingsToUpdate")
1073
+ .mockResolvedValue(undefined),
1074
+ );
1075
+
1076
+ // Workflows and realtime of every model the declaration writes.
1077
+ own(
1078
+ jest
1079
+ .spyOn(IncidentService, "onTriggerWorkflow")
1080
+ .mockResolvedValue(undefined),
1081
+ );
1082
+ own(
1083
+ jest
1084
+ .spyOn(IncidentService, "onTriggerRealtime")
1085
+ .mockResolvedValue(undefined),
1086
+ );
1087
+ own(
1088
+ jest
1089
+ .spyOn(AlertService, "onTriggerWorkflow")
1090
+ .mockResolvedValue(undefined),
1091
+ );
1092
+ own(
1093
+ jest
1094
+ .spyOn(AlertService, "onTriggerRealtime")
1095
+ .mockResolvedValue(undefined),
1096
+ );
1097
+ own(
1098
+ jest
1099
+ .spyOn(AlertStateTimelineService, "onTriggerWorkflow")
1100
+ .mockResolvedValue(undefined),
1101
+ );
1102
+ own(
1103
+ jest
1104
+ .spyOn(AlertStateTimelineService, "onTriggerRealtime")
1105
+ .mockResolvedValue(undefined),
1106
+ );
1107
+
1108
+ // The un-awaited onCreateSuccess chain: none of it moves an alert.
1109
+ const incidentInternals: Record<string, () => Promise<void>> =
1110
+ IncidentService as unknown as Record<string, () => Promise<void>>;
1111
+ for (const method of [
1112
+ "handleIncidentWorkspaceOperationsAsync",
1113
+ "createIncidentFeedAsync",
1114
+ "handleIncidentStateChangeAsync",
1115
+ "disableActiveMonitoringIfManualIncident",
1116
+ "refreshReminderSchedule",
1117
+ ]) {
1118
+ own(jest.spyOn(incidentInternals, method).mockResolvedValue(undefined));
1119
+ }
1120
+ own(
1121
+ jest
1122
+ .spyOn(IncidentPrivacyRuleEngineService, "applyRulesToIncident")
1123
+ .mockResolvedValue(false),
1124
+ );
1125
+ own(
1126
+ jest
1127
+ .spyOn(IncidentOwnerRuleEngineService, "applyRulesToIncident")
1128
+ .mockResolvedValue(undefined),
1129
+ );
1130
+ own(
1131
+ jest
1132
+ .spyOn(IncidentLabelRuleEngineService, "applyRulesToIncident")
1133
+ .mockResolvedValue(undefined),
1134
+ );
1135
+ own(
1136
+ jest
1137
+ .spyOn(IncidentOnCallRuleEngineService, "applyRulesToIncident")
1138
+ .mockResolvedValue(undefined),
1139
+ );
1140
+ own(
1141
+ jest
1142
+ .spyOn(RunbookRuleEngineService, "applyRulesToIncident")
1143
+ .mockResolvedValue(undefined),
1144
+ );
1145
+ own(
1146
+ jest
1147
+ .spyOn(AutoRemediationRuleEngineService, "applyRulesToIncident")
1148
+ .mockResolvedValue(undefined),
1149
+ );
1150
+ own(
1151
+ jest
1152
+ .spyOn(IncidentGroupingEngineService, "processIncident")
1153
+ .mockResolvedValue({ grouped: false }),
1154
+ );
1155
+ own(
1156
+ jest
1157
+ .spyOn(IncidentSlaService, "createSlaForIncident")
1158
+ .mockResolvedValue(null),
1159
+ );
1160
+ own(
1161
+ jest
1162
+ .spyOn(AIIncidentInvestigationRunner, "investigateNewIncident")
1163
+ .mockResolvedValue(false),
1164
+ );
1165
+ own(
1166
+ jest
1167
+ .spyOn(IncidentAlertService, "createDeclaredFromAlertsFeedItem")
1168
+ .mockResolvedValue(undefined),
1169
+ );
1170
+ own(
1171
+ jest
1172
+ .spyOn(IncidentAlertService, "copyAlertOwnersToIncident")
1173
+ .mockResolvedValue({ userIds: [], teamIds: [] }),
1174
+ );
1175
+ // A member who declares becomes its owner: owner tables are not cloned.
1176
+ own(
1177
+ jest
1178
+ .spyOn(IncidentOwnerUserService, "create")
1179
+ .mockResolvedValue(undefined as never),
1180
+ );
1181
+
1182
+ // What an alert state change sets off besides the change itself.
1183
+ own(
1184
+ jest
1185
+ .spyOn(AlertService, "refreshAlertMetrics")
1186
+ .mockResolvedValue(undefined),
1187
+ );
1188
+ own(
1189
+ jest
1190
+ .spyOn(AlertMeasurementValueService, "recomputeForAlert")
1191
+ .mockResolvedValue(undefined),
1192
+ );
1193
+ own(
1194
+ jest
1195
+ .spyOn(WorkspaceNotificationRuleService, "archiveWorkspaceChannels")
1196
+ .mockResolvedValue(undefined),
1197
+ );
1198
+
1199
+ // The stub set up for the whole suite: the alerts' feed entries.
1200
+ alertFeed = jest.spyOn(AlertFeedService, "createAlertFeedItem");
1201
+
1202
+ /*
1203
+ * The real acknowledgement, recording how many links the table held
1204
+ * when it started. Its promise is kept by the spy (mock.results), so a
1205
+ * test can wait for the un-awaited work to finish.
1206
+ */
1207
+ const acknowledgeForReal: (
1208
+ data: AcknowledgeArguments,
1209
+ ) => Promise<AcknowledgeDeclaredAlertsResult> =
1210
+ IncidentAlertService.acknowledgeAlertsDeclaredWithIncident.bind(
1211
+ IncidentAlertService,
1212
+ );
1213
+ acknowledge = jest
1214
+ .spyOn(IncidentAlertService, "acknowledgeAlertsDeclaredWithIncident")
1215
+ .mockImplementation(
1216
+ async (
1217
+ data: AcknowledgeArguments,
1218
+ ): Promise<AcknowledgeDeclaredAlertsResult> => {
1219
+ linksWhenAcknowledging.push(await linkCount());
1220
+ return acknowledgeForReal(data);
1221
+ },
1222
+ );
1223
+ own(acknowledge);
1224
+ });
1225
+
1226
+ beforeEach(async () => {
1227
+ await database.query(
1228
+ DECLARE_TABLES.map((table: string): string => {
1229
+ return `DELETE FROM "${schema}"."${table}"`;
1230
+ }).join("; "),
1231
+ );
1232
+
1233
+ alertFeed.mockClear();
1234
+ acknowledge.mockClear();
1235
+ linksWhenAcknowledging = [];
1236
+
1237
+ alertStates = {
1238
+ created: await seedAlertState("Created", 1, "isCreatedState"),
1239
+ acknowledged: await seedAlertState(
1240
+ "Acknowledged",
1241
+ 2,
1242
+ "isAcknowledgedState",
1243
+ ),
1244
+ resolved: await seedAlertState("Resolved", 3, "isResolvedState"),
1245
+ };
1246
+
1247
+ incidentStates = {
1248
+ created: await seedIncidentState("Identified", 1, "isCreatedState"),
1249
+ acknowledged: await seedIncidentState(
1250
+ "Acknowledged",
1251
+ 2,
1252
+ "isAcknowledgedState",
1253
+ ),
1254
+ resolved: await seedIncidentState("Resolved", 3, "isResolvedState"),
1255
+ };
1256
+
1257
+ incidentSeverityId = await seedIncidentSeverity();
1258
+ });
1259
+
1260
+ afterAll(() => {
1261
+ for (const spy of ownSpies) {
1262
+ spy.mockRestore();
1263
+ }
1264
+ });
1265
+
1266
+ async function seedAlertState(
1267
+ name: string,
1268
+ order: number,
1269
+ flag: StateFlag,
1270
+ ): Promise<ObjectID> {
1271
+ const id: ObjectID = ObjectID.generate();
1272
+ await database.query(
1273
+ `INSERT INTO "${schema}"."AlertState"
1274
+ ("_id", "projectId", "name", "color", "order", "isCreatedState", "isAcknowledgedState", "isResolvedState", "version")
1275
+ VALUES ($1, $2, $3, '#6b7280', $4, $5, $6, $7, 1)`,
1276
+ [
1277
+ id.toString(),
1278
+ projectId.toString(),
1279
+ name,
1280
+ order,
1281
+ flag === "isCreatedState",
1282
+ flag === "isAcknowledgedState",
1283
+ flag === "isResolvedState",
1284
+ ],
1285
+ );
1286
+ return id;
1287
+ }
1288
+
1289
+ async function seedIncidentState(
1290
+ name: string,
1291
+ order: number,
1292
+ flag: StateFlag,
1293
+ ): Promise<ObjectID> {
1294
+ const id: ObjectID = ObjectID.generate();
1295
+ await database.query(
1296
+ `INSERT INTO "${schema}"."IncidentState"
1297
+ ("_id", "projectId", "name", "slug", "color", "order", "isCreatedState", "isAcknowledgedState", "isResolvedState", "version")
1298
+ VALUES ($1, $2, $3, $4, '#6b7280', $5, $6, $7, $8, 1)`,
1299
+ [
1300
+ id.toString(),
1301
+ projectId.toString(),
1302
+ name,
1303
+ `${name.toLowerCase()}-${id.toString()}`,
1304
+ order,
1305
+ flag === "isCreatedState",
1306
+ flag === "isAcknowledgedState",
1307
+ flag === "isResolvedState",
1308
+ ],
1309
+ );
1310
+ return id;
1311
+ }
1312
+
1313
+ async function seedIncidentSeverity(): Promise<ObjectID> {
1314
+ const id: ObjectID = ObjectID.generate();
1315
+ await database.query(
1316
+ `INSERT INTO "${schema}"."IncidentSeverity"
1317
+ ("_id", "projectId", "name", "slug", "color", "order", "version")
1318
+ VALUES ($1, $2, 'Critical', $3, '#dc2626', 1, 1)`,
1319
+ [id.toString(), projectId.toString(), `critical-${id.toString()}`],
1320
+ );
1321
+ return id;
1322
+ }
1323
+
1324
+ /*
1325
+ * An alert that went through the given states, the last one current:
1326
+ * ten minutes apart, the last one starting ten minutes ago, each row
1327
+ * ending where the next begins - as AlertStateTimelineService writes
1328
+ * them.
1329
+ */
1330
+ async function seedAlertThrough(
1331
+ states: Array<ObjectID>,
1332
+ options: { project?: ObjectID; isPrivate?: boolean } = {},
1333
+ ): Promise<ObjectID> {
1334
+ const id: ObjectID = ObjectID.generate();
1335
+ const alertProjectId: ObjectID = options.project || projectId;
1336
+
1337
+ await database.query(
1338
+ `INSERT INTO "${schema}"."Alert"
1339
+ ("_id", "projectId", "title", "currentAlertStateId", "alertSeverityId", "isPrivate", "version")
1340
+ VALUES ($1, $2, 'Checkout p95 latency is high', $3, $4, $5, 1)`,
1341
+ [
1342
+ id.toString(),
1343
+ alertProjectId.toString(),
1344
+ states[states.length - 1]!.toString(),
1345
+ ObjectID.generate().toString(),
1346
+ Boolean(options.isPrivate),
1347
+ ],
1348
+ );
1349
+
1350
+ const tenMinutes: number = 10 * 60 * 1000;
1351
+ const now: number = Date.now();
1352
+
1353
+ for (let index: number = 0; index < states.length; index++) {
1354
+ const startsAt: Date = new Date(
1355
+ now - (states.length - index) * tenMinutes,
1356
+ );
1357
+ const endsAt: Date | null =
1358
+ index < states.length - 1
1359
+ ? new Date(startsAt.getTime() + tenMinutes)
1360
+ : null;
1361
+
1362
+ await database.query(
1363
+ `INSERT INTO "${schema}"."AlertStateTimeline"
1364
+ ("_id", "projectId", "alertId", "alertStateId", "startsAt", "endsAt", "isOwnerNotified", "version")
1365
+ VALUES ($1, $2, $3, $4, $5, $6, true, 1)`,
1366
+ [
1367
+ ObjectID.generate().toString(),
1368
+ alertProjectId.toString(),
1369
+ id.toString(),
1370
+ states[index]!.toString(),
1371
+ startsAt,
1372
+ endsAt,
1373
+ ],
1374
+ );
1375
+ }
1376
+
1377
+ return id;
1378
+ }
1379
+
1380
+ async function currentAlertStateOf(alertId: ObjectID): Promise<string> {
1381
+ const rows: Array<{ currentAlertStateId: string }> = await database.query(
1382
+ `SELECT "currentAlertStateId" FROM "${schema}"."Alert" WHERE "_id" = $1`,
1383
+ [alertId.toString()],
1384
+ );
1385
+ return rows[0]!.currentAlertStateId;
1386
+ }
1387
+
1388
+ async function timelineOf(alertId: ObjectID): Promise<Array<TimelineRow>> {
1389
+ return database.query(
1390
+ `SELECT "alertStateId", "createdByUserId", "rootCause", "isOwnerNotified", "startsAt", "endsAt"
1391
+ FROM "${schema}"."AlertStateTimeline"
1392
+ WHERE "alertId" = $1
1393
+ ORDER BY "startsAt"`,
1394
+ [alertId.toString()],
1395
+ );
1396
+ }
1397
+
1398
+ async function incidentRows(): Promise<Array<IncidentRow>> {
1399
+ return database.query(
1400
+ `SELECT "_id", "incidentNumber", "currentIncidentStateId", "isPrivate"
1401
+ FROM "${schema}"."Incident"
1402
+ ORDER BY "incidentNumber"`,
1403
+ );
1404
+ }
1405
+
1406
+ async function incidentCounter(): Promise<number> {
1407
+ const rows: Array<{ incidentCounter: number | string }> =
1408
+ await database.query(
1409
+ `SELECT "incidentCounter" FROM "${schema}"."Project" WHERE "_id" = $1`,
1410
+ [projectId.toString()],
1411
+ );
1412
+ return Number(rows[0]!.incidentCounter);
1413
+ }
1414
+
1415
+ // Polls, because the create does not wait for the acknowledgement.
1416
+ async function waitForAlertState(
1417
+ alertId: ObjectID,
1418
+ stateId: ObjectID,
1419
+ ): Promise<void> {
1420
+ const deadline: number = Date.now() + ACKNOWLEDGEMENT_TIMEOUT_MS;
1421
+ let current: string = await currentAlertStateOf(alertId);
1422
+
1423
+ while (current !== stateId.toString()) {
1424
+ if (Date.now() > deadline) {
1425
+ throw new Error(
1426
+ `alert ${alertId.toString()} is in state ${current}, not ${stateId.toString()}, ${ACKNOWLEDGEMENT_TIMEOUT_MS}ms after the incident was declared`,
1427
+ );
1428
+ }
1429
+
1430
+ await new Promise<void>((resolve: () => void) => {
1431
+ setTimeout(resolve, 50);
1432
+ });
1433
+
1434
+ current = await currentAlertStateOf(alertId);
1435
+ }
1436
+ }
1437
+
1438
+ // The one acknowledgement the declaration started, once it has finished.
1439
+ async function acknowledgement(): Promise<AcknowledgeDeclaredAlertsResult> {
1440
+ expect(acknowledge).toHaveBeenCalledTimes(1);
1441
+ return (await acknowledge.mock.results[0]!
1442
+ .value) as AcknowledgeDeclaredAlertsResult;
1443
+ }
1444
+
1445
+ function acknowledgementArguments(): AcknowledgeArguments {
1446
+ return acknowledge.mock.calls[0]![0] as AcknowledgeArguments;
1447
+ }
1448
+
1449
+ function ids(values: Array<ObjectID>): Array<string> {
1450
+ return values.map((value: ObjectID): string => {
1451
+ return value.toString();
1452
+ });
1453
+ }
1454
+
1455
+ // The alert feed entries of state changes (links write their own).
1456
+ function stateChangeFeedItems(): Array<AlertFeedItem> {
1457
+ return alertFeed.mock.calls
1458
+ .map((call: Array<unknown>): AlertFeedItem => {
1459
+ return call[0] as AlertFeedItem;
1460
+ })
1461
+ .filter((item: AlertFeedItem): boolean => {
1462
+ return (
1463
+ item.alertFeedEventType === AlertFeedEventType.AlertStateChanged
1464
+ );
1465
+ });
1466
+ }
1467
+
1468
+ function newIncident(
1469
+ options: {
1470
+ createdByUserId?: ObjectID;
1471
+ isPrivate?: boolean;
1472
+ currentIncidentStateId?: ObjectID;
1473
+ } = {},
1474
+ ): Incident {
1475
+ const incident: Incident = new Incident();
1476
+ incident.projectId = projectId;
1477
+ incident.title = "Checkout is failing";
1478
+ incident.incidentSeverityId = incidentSeverityId;
1479
+
1480
+ if (options.createdByUserId) {
1481
+ incident.createdByUserId = options.createdByUserId;
1482
+ }
1483
+
1484
+ if (options.isPrivate) {
1485
+ incident.isPrivate = true;
1486
+ }
1487
+
1488
+ if (options.currentIncidentStateId) {
1489
+ incident.currentIncidentStateId = options.currentIncidentStateId;
1490
+ }
1491
+
1492
+ return incident;
1493
+ }
1494
+
1495
+ // miscDataProps of a declaration from these alerts.
1496
+ function declaredFrom(
1497
+ alertIds: Array<ObjectID>,
1498
+ acknowledgeAlerts?: boolean,
1499
+ ): JSONObject {
1500
+ const miscDataProps: JSONObject = {
1501
+ [INCIDENT_ALERT_IDS_TO_LINK_KEY]: ids(alertIds),
1502
+ };
1503
+
1504
+ if (acknowledgeAlerts !== undefined) {
1505
+ miscDataProps[INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY] =
1506
+ acknowledgeAlerts;
1507
+ }
1508
+
1509
+ return miscDataProps;
1510
+ }
1511
+
1512
+ // A user of the test project holding exactly these permissions.
1513
+ function userProps(
1514
+ userId: ObjectID,
1515
+ permissions: Array<Permission>,
1516
+ ): DatabaseCommonInteractionProps {
1517
+ const tenantPermission: UserTenantAccessPermission = {
1518
+ projectId: projectId,
1519
+ _type: "UserTenantAccessPermission",
1520
+ permissions: permissions.map(
1521
+ (permission: Permission): UserPermission => {
1522
+ return {
1523
+ _type: "UserPermission",
1524
+ permission: permission,
1525
+ labelIds: [],
1526
+ isBlockPermission: false,
1527
+ };
1528
+ },
1529
+ ),
1530
+ };
1531
+
1532
+ return {
1533
+ tenantId: projectId,
1534
+ userId: userId,
1535
+ userType: UserType.User,
1536
+ userTenantAccessPermission: {
1537
+ [projectId.toString()]: tenantPermission,
1538
+ },
1539
+ };
1540
+ }
1541
+
1542
+ /*
1543
+ * A responder who can read every alert (Viewer) and declare incidents
1544
+ * and link alerts to them (IncidentMember), but may change only the
1545
+ * alerts carrying one label (AlertMember scoped to that label).
1546
+ */
1547
+ function labelScopedMemberProps(
1548
+ userId: ObjectID,
1549
+ alertLabelId: ObjectID,
1550
+ ): DatabaseCommonInteractionProps {
1551
+ const props: DatabaseCommonInteractionProps = userProps(userId, [
1552
+ Permission.Viewer,
1553
+ Permission.IncidentMember,
1554
+ ]);
1555
+
1556
+ const tenantPermission: UserTenantAccessPermission =
1557
+ props.userTenantAccessPermission![projectId.toString()]!;
1558
+
1559
+ tenantPermission.permissions.push({
1560
+ _type: "UserPermission",
1561
+ permission: Permission.AlertMember,
1562
+ labelIds: [alertLabelId],
1563
+ isBlockPermission: false,
1564
+ scope: PermissionScope.Labels,
1565
+ });
1566
+
1567
+ return props;
1568
+ }
1569
+
1570
+ async function seedLabel(name: string): Promise<ObjectID> {
1571
+ const id: ObjectID = ObjectID.generate();
1572
+ await database.query(
1573
+ `INSERT INTO "${schema}"."Label" ("_id", "projectId", "name", "slug", "color", "version")
1574
+ VALUES ($1, $2, $3, $4, '#2563eb', 1)`,
1575
+ [
1576
+ id.toString(),
1577
+ projectId.toString(),
1578
+ name,
1579
+ `${name.toLowerCase()}-${id.toString()}`,
1580
+ ],
1581
+ );
1582
+ return id;
1583
+ }
1584
+
1585
+ async function labelAlert(
1586
+ alertId: ObjectID,
1587
+ labelId: ObjectID,
1588
+ ): Promise<ObjectID> {
1589
+ await database.query(
1590
+ `INSERT INTO "${schema}"."AlertLabel" ("alertId", "labelId") VALUES ($1, $2)`,
1591
+ [alertId.toString(), labelId.toString()],
1592
+ );
1593
+ return alertId;
1594
+ }
1595
+
1596
+ async function validateAcknowledge(
1597
+ alertIds: Array<ObjectID>,
1598
+ props: DatabaseCommonInteractionProps,
1599
+ ): Promise<CheckedAcknowledgement | null> {
1600
+ const checked: AlertsToAcknowledgeOnDeclare | null =
1601
+ await IncidentAlertService.validateAcknowledgeAlertsForNewIncident({
1602
+ projectId: projectId,
1603
+ acknowledgeAlerts: true,
1604
+ alertIds: alertIds,
1605
+ props: props,
1606
+ });
1607
+
1608
+ if (!checked) {
1609
+ return null;
1610
+ }
1611
+
1612
+ return {
1613
+ acknowledgedAlertStateId: checked.acknowledgedAlertStateId.toString(),
1614
+ alertIdsToAcknowledge: ids(checked.alertIdsToAcknowledge),
1615
+ };
1616
+ }
1617
+
1618
+ function timelineStates(timeline: Array<TimelineRow>): Array<string> {
1619
+ return timeline.map((row: TimelineRow): string => {
1620
+ return row.alertStateId;
1621
+ });
1622
+ }
1623
+
1624
+ test("root declares with acknowledgeAlertsToLink: once linked, the alert is acknowledged as the declaring user", async () => {
1625
+ const userId: ObjectID = await seedUser();
1626
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1627
+
1628
+ const incident: Incident = await IncidentService.create({
1629
+ data: newIncident({ createdByUserId: userId }),
1630
+ miscDataProps: declaredFrom([alertId], true),
1631
+ props: { isRoot: true },
1632
+ });
1633
+
1634
+ expect(incident.incidentNumber).toBe(1);
1635
+ expect(incident.incidentNumberWithPrefix).toBe("#1");
1636
+
1637
+ await waitForAlertState(alertId, alertStates.acknowledged);
1638
+
1639
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
1640
+
1641
+ expect(ids(result.acknowledgedAlertIds)).toEqual([alertId.toString()]);
1642
+ expect(result.alreadyAcknowledgedAlertIds).toEqual([]);
1643
+ expect(result.leftToLinkedAlertSyncAlertIds).toEqual([]);
1644
+ expect(result.failed).toEqual([]);
1645
+
1646
+ const args: AcknowledgeArguments = acknowledgementArguments();
1647
+ expect(args.projectId.toString()).toBe(projectId.toString());
1648
+ expect(args.incidentId.toString()).toBe(incident.id!.toString());
1649
+ expect(ids(args.alertIds)).toEqual([alertId.toString()]);
1650
+ expect(ids(args.linkedAlertIds)).toEqual([alertId.toString()]);
1651
+ expect(String(args.acknowledgedByUserId)).toBe(userId.toString());
1652
+
1653
+ // It started once the link was in the table, which names the user too.
1654
+ expect(linksWhenAcknowledging).toEqual([1]);
1655
+ expect(await linkRows()).toEqual([
1656
+ { _id: expect.any(String), createdByUserId: userId.toString() },
1657
+ ]);
1658
+
1659
+ const timeline: Array<TimelineRow> = await timelineOf(alertId);
1660
+
1661
+ expect(
1662
+ timeline.map((row: TimelineRow): string => {
1663
+ return row.alertStateId;
1664
+ }),
1665
+ ).toEqual([
1666
+ alertStates.created.toString(),
1667
+ alertStates.acknowledged.toString(),
1668
+ ]);
1669
+ expect(timeline[1]).toMatchObject({
1670
+ createdByUserId: userId.toString(),
1671
+ rootCause:
1672
+ "Acknowledged because Incident #1 was declared from this alert.",
1673
+ // The owners are told, as when Acknowledge is pressed on the alert.
1674
+ isOwnerNotified: false,
1675
+ endsAt: null,
1676
+ });
1677
+ // The Created row now ends where the acknowledgement begins.
1678
+ expect(timeline[0]!.endsAt?.getTime()).toBe(
1679
+ timeline[1]!.startsAt.getTime(),
1680
+ );
1681
+
1682
+ // The alert's feed says who and why.
1683
+ const feedItems: Array<AlertFeedItem> = stateChangeFeedItems();
1684
+ expect(feedItems).toHaveLength(1);
1685
+ expect(feedItems[0]!.alertId.toString()).toBe(alertId.toString());
1686
+ expect(String(feedItems[0]!.userId)).toBe(userId.toString());
1687
+ expect(String(feedItems[0]!.workspaceNotification?.notifyUserId)).toBe(
1688
+ userId.toString(),
1689
+ );
1690
+ expect(feedItems[0]!.moreInformationInMarkdown).toContain(
1691
+ "Acknowledged because Incident #1 was declared from this alert.",
1692
+ );
1693
+
1694
+ // The incident itself is left in the state it was declared in.
1695
+ expect(await incidentRows()).toEqual([
1696
+ {
1697
+ _id: incident.id!.toString(),
1698
+ incidentNumber: 1,
1699
+ currentIncidentStateId: incidentStates.created.toString(),
1700
+ isPrivate: false,
1701
+ },
1702
+ ]);
1703
+ });
1704
+
1705
+ test("alerts already acknowledged or resolved are left as they are: only the one still open is handed to the acknowledgement, and acknowledged", async () => {
1706
+ await database.query(
1707
+ `UPDATE "${schema}"."Project" SET "incidentNumberPrefix" = 'INC-' WHERE "_id" = $1`,
1708
+ [projectId.toString()],
1709
+ );
1710
+
1711
+ const userId: ObjectID = await seedUser();
1712
+ const openAlertId: ObjectID = await seedAlertThrough([
1713
+ alertStates.created,
1714
+ ]);
1715
+ const acknowledgedAlertId: ObjectID = await seedAlertThrough([
1716
+ alertStates.created,
1717
+ alertStates.acknowledged,
1718
+ ]);
1719
+ const resolvedAlertId: ObjectID = await seedAlertThrough([
1720
+ alertStates.created,
1721
+ alertStates.resolved,
1722
+ ]);
1723
+
1724
+ const acknowledgedTimeline: Array<TimelineRow> =
1725
+ await timelineOf(acknowledgedAlertId);
1726
+ const resolvedTimeline: Array<TimelineRow> =
1727
+ await timelineOf(resolvedAlertId);
1728
+
1729
+ const incident: Incident = await IncidentService.create({
1730
+ data: newIncident({ createdByUserId: userId }),
1731
+ miscDataProps: declaredFrom(
1732
+ [acknowledgedAlertId, openAlertId, resolvedAlertId],
1733
+ true,
1734
+ ),
1735
+ props: { isRoot: true },
1736
+ });
1737
+
1738
+ expect(incident.incidentNumberWithPrefix).toBe("INC-1");
1739
+
1740
+ await waitForAlertState(openAlertId, alertStates.acknowledged);
1741
+
1742
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
1743
+
1744
+ /*
1745
+ * The two already past Created were settled when the declaration was
1746
+ * checked: only the open alert is handed on to be written, while all
1747
+ * three are passed as linked (the sync decides on those).
1748
+ */
1749
+ const args: AcknowledgeArguments = acknowledgementArguments();
1750
+ expect(ids(args.alertIds)).toEqual([openAlertId.toString()]);
1751
+ expect(ids(args.linkedAlertIds)).toEqual([
1752
+ acknowledgedAlertId.toString(),
1753
+ openAlertId.toString(),
1754
+ resolvedAlertId.toString(),
1755
+ ]);
1756
+
1757
+ expect(ids(result.acknowledgedAlertIds)).toEqual([
1758
+ openAlertId.toString(),
1759
+ ]);
1760
+ expect(result.alreadyAcknowledgedAlertIds).toEqual([]);
1761
+ expect(result.leftToLinkedAlertSyncAlertIds).toEqual([]);
1762
+ expect(result.failed).toEqual([]);
1763
+
1764
+ // All three are linked; nothing is moved backwards, or written twice.
1765
+ expect(await linkCount()).toBe(3);
1766
+ expect(await currentAlertStateOf(acknowledgedAlertId)).toBe(
1767
+ alertStates.acknowledged.toString(),
1768
+ );
1769
+ expect(await currentAlertStateOf(resolvedAlertId)).toBe(
1770
+ alertStates.resolved.toString(),
1771
+ );
1772
+ expect(await timelineOf(acknowledgedAlertId)).toEqual(
1773
+ acknowledgedTimeline,
1774
+ );
1775
+ expect(await timelineOf(resolvedAlertId)).toEqual(resolvedTimeline);
1776
+
1777
+ const openTimeline: Array<TimelineRow> = await timelineOf(openAlertId);
1778
+ expect(openTimeline).toHaveLength(2);
1779
+ expect(openTimeline[1]).toMatchObject({
1780
+ alertStateId: alertStates.acknowledged.toString(),
1781
+ createdByUserId: userId.toString(),
1782
+ rootCause:
1783
+ "Acknowledged because Incident INC-1 was declared from this alert.",
1784
+ });
1785
+
1786
+ const feedItems: Array<AlertFeedItem> = stateChangeFeedItems();
1787
+ expect(
1788
+ feedItems.map((item: AlertFeedItem): string => {
1789
+ return item.alertId.toString();
1790
+ }),
1791
+ ).toEqual([openAlertId.toString()]);
1792
+ });
1793
+
1794
+ test("without acknowledgeAlertsToLink (absent or false) the alerts are linked and left in their state", async () => {
1795
+ const userId: ObjectID = await seedUser();
1796
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1797
+
1798
+ const first: Incident = await IncidentService.create({
1799
+ data: newIncident({ createdByUserId: userId }),
1800
+ miscDataProps: declaredFrom([alertId]),
1801
+ props: { isRoot: true },
1802
+ });
1803
+
1804
+ const second: Incident = await IncidentService.create({
1805
+ data: newIncident({ createdByUserId: userId }),
1806
+ miscDataProps: declaredFrom([alertId], false),
1807
+ props: { isRoot: true },
1808
+ });
1809
+
1810
+ expect(first.incidentNumber).toBe(1);
1811
+ expect(second.incidentNumber).toBe(2);
1812
+
1813
+ /*
1814
+ * The acknowledgement is started before create() returns (only its
1815
+ * result is not waited for), so not having started by now is final.
1816
+ */
1817
+ expect(acknowledge).not.toHaveBeenCalled();
1818
+ expect(await linkCount()).toBe(2);
1819
+ expect(await currentAlertStateOf(alertId)).toBe(
1820
+ alertStates.created.toString(),
1821
+ );
1822
+ expect(await timelineOf(alertId)).toHaveLength(1);
1823
+ expect(stateChangeFeedItems()).toEqual([]);
1824
+ });
1825
+
1826
+ test("a project without an Acknowledged alert state: the declaration is refused before the incident, its number or its links exist", async () => {
1827
+ await database.query(
1828
+ `DELETE FROM "${schema}"."AlertState" WHERE "_id" = $1`,
1829
+ [alertStates.acknowledged.toString()],
1830
+ );
1831
+
1832
+ const userId: ObjectID = await seedUser();
1833
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1834
+
1835
+ const refusal: unknown = await failureOf(() => {
1836
+ return IncidentService.create({
1837
+ data: newIncident({ createdByUserId: userId }),
1838
+ miscDataProps: declaredFrom([alertId], true),
1839
+ props: { isRoot: true },
1840
+ });
1841
+ });
1842
+
1843
+ expect(refusal).toBeInstanceOf(BadDataException);
1844
+ expect((refusal as Error).message).toBe(NO_ACKNOWLEDGED_STATE_MESSAGE);
1845
+
1846
+ expect(await incidentRows()).toEqual([]);
1847
+ expect(await incidentCounter()).toBe(0);
1848
+ expect(await linkCount()).toBe(0);
1849
+ expect(acknowledge).not.toHaveBeenCalled();
1850
+ expect(await currentAlertStateOf(alertId)).toBe(
1851
+ alertStates.created.toString(),
1852
+ );
1853
+
1854
+ // Declared without asking, it goes through - and takes number 1.
1855
+ const incident: Incident = await IncidentService.create({
1856
+ data: newIncident({ createdByUserId: userId }),
1857
+ miscDataProps: declaredFrom([alertId]),
1858
+ props: { isRoot: true },
1859
+ });
1860
+
1861
+ expect(incident.incidentNumber).toBe(1);
1862
+ expect(await linkCount()).toBe(1);
1863
+ expect(await currentAlertStateOf(alertId)).toBe(
1864
+ alertStates.created.toString(),
1865
+ );
1866
+ });
1867
+
1868
+ test("a private incident is not named in the acknowledgement's cause", async () => {
1869
+ const userId: ObjectID = await seedUser();
1870
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1871
+
1872
+ const incident: Incident = await IncidentService.create({
1873
+ data: newIncident({ createdByUserId: userId, isPrivate: true }),
1874
+ miscDataProps: declaredFrom([alertId], true),
1875
+ props: { isRoot: true },
1876
+ });
1877
+
1878
+ expect(incident.incidentNumberWithPrefix).toBe("#1");
1879
+
1880
+ await waitForAlertState(alertId, alertStates.acknowledged);
1881
+
1882
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
1883
+ expect(ids(result.acknowledgedAlertIds)).toEqual([alertId.toString()]);
1884
+
1885
+ expect((await incidentRows())[0]!.isPrivate).toBe(true);
1886
+
1887
+ const timeline: Array<TimelineRow> = await timelineOf(alertId);
1888
+ expect(timeline).toHaveLength(2);
1889
+ expect(timeline[1]).toMatchObject({
1890
+ alertStateId: alertStates.acknowledged.toString(),
1891
+ createdByUserId: userId.toString(),
1892
+ rootCause: PRIVATE_INCIDENT_CAUSE,
1893
+ });
1894
+
1895
+ // The feed entry goes to the alert's channels: no incident number.
1896
+ const feedItems: Array<AlertFeedItem> = stateChangeFeedItems();
1897
+ expect(feedItems).toHaveLength(1);
1898
+ expect(feedItems[0]!.moreInformationInMarkdown).toContain(
1899
+ PRIVATE_INCIDENT_CAUSE,
1900
+ );
1901
+ expect(feedItems[0]!.moreInformationInMarkdown).not.toContain("#1");
1902
+ });
1903
+
1904
+ test("a member who may acknowledge alerts declares, and the acknowledgement is credited to them", async () => {
1905
+ const userId: ObjectID = await seedUser();
1906
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1907
+
1908
+ const incident: Incident = await IncidentService.create({
1909
+ data: newIncident(),
1910
+ miscDataProps: declaredFrom([alertId], true),
1911
+ props: userProps(userId, [
1912
+ Permission.IncidentMember,
1913
+ Permission.AlertMember,
1914
+ ]),
1915
+ });
1916
+
1917
+ expect(incident.incidentNumber).toBe(1);
1918
+
1919
+ await waitForAlertState(alertId, alertStates.acknowledged);
1920
+
1921
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
1922
+ expect(ids(result.acknowledgedAlertIds)).toEqual([alertId.toString()]);
1923
+ expect(result.failed).toEqual([]);
1924
+
1925
+ expect(String(acknowledgementArguments().acknowledgedByUserId)).toBe(
1926
+ userId.toString(),
1927
+ );
1928
+
1929
+ const timeline: Array<TimelineRow> = await timelineOf(alertId);
1930
+ expect(timeline).toHaveLength(2);
1931
+ expect(timeline[1]).toMatchObject({
1932
+ alertStateId: alertStates.acknowledged.toString(),
1933
+ createdByUserId: userId.toString(),
1934
+ rootCause:
1935
+ "Acknowledged because Incident #1 was declared from this alert.",
1936
+ });
1937
+ expect(await linkRows()).toEqual([
1938
+ { _id: expect.any(String), createdByUserId: userId.toString() },
1939
+ ]);
1940
+ });
1941
+
1942
+ test("a member who may not change alert states is refused before the incident exists", async () => {
1943
+ const userId: ObjectID = await seedUser();
1944
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1945
+
1946
+ // May declare incidents and link alerts, and only read alerts.
1947
+ const viewer: DatabaseCommonInteractionProps = userProps(userId, [
1948
+ Permission.IncidentMember,
1949
+ Permission.AlertViewer,
1950
+ ]);
1951
+
1952
+ const refusal: unknown = await failureOf(() => {
1953
+ return IncidentService.create({
1954
+ data: newIncident(),
1955
+ miscDataProps: declaredFrom([alertId], true),
1956
+ props: viewer,
1957
+ });
1958
+ });
1959
+
1960
+ expect(refusal).toBeInstanceOf(BadDataException);
1961
+ expect((refusal as Error).message).toBe(
1962
+ NO_PERMISSION_TO_ACKNOWLEDGE_MESSAGE,
1963
+ );
1964
+
1965
+ expect(await incidentRows()).toEqual([]);
1966
+ expect(await incidentCounter()).toBe(0);
1967
+ expect(await linkCount()).toBe(0);
1968
+ expect(acknowledge).not.toHaveBeenCalled();
1969
+ expect(await currentAlertStateOf(alertId)).toBe(
1970
+ alertStates.created.toString(),
1971
+ );
1972
+ expect(await timelineOf(alertId)).toHaveLength(1);
1973
+
1974
+ // The same declaration without asking to acknowledge is theirs to make.
1975
+ const incident: Incident = await IncidentService.create({
1976
+ data: newIncident(),
1977
+ miscDataProps: declaredFrom([alertId]),
1978
+ props: viewer,
1979
+ });
1980
+
1981
+ expect(incident.incidentNumber).toBe(1);
1982
+ expect(await linkRows()).toEqual([
1983
+ { _id: expect.any(String), createdByUserId: userId.toString() },
1984
+ ]);
1985
+ expect(await currentAlertStateOf(alertId)).toBe(
1986
+ alertStates.created.toString(),
1987
+ );
1988
+ });
1989
+
1990
+ test("the permission check runs on the real tables: a member's update scope leaves out a private alert they do not own, and another project's alert is never one to acknowledge", async () => {
1991
+ const userId: ObjectID = await seedUser();
1992
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
1993
+ const privateAlertId: ObjectID = await seedAlertThrough(
1994
+ [alertStates.created],
1995
+ { isPrivate: true },
1996
+ );
1997
+ const foreignAlertId: ObjectID = await seedAlertThrough(
1998
+ [alertStates.created],
1999
+ { project: otherProjectId },
2000
+ );
2001
+
2002
+ const member: DatabaseCommonInteractionProps = userProps(userId, [
2003
+ Permission.IncidentMember,
2004
+ Permission.AlertMember,
2005
+ ]);
2006
+
2007
+ expect(await validateAcknowledge([alertId], member)).toEqual({
2008
+ acknowledgedAlertStateId: alertStates.acknowledged.toString(),
2009
+ alertIdsToAcknowledge: [alertId.toString()],
2010
+ });
2011
+
2012
+ await expect(
2013
+ validateAcknowledge([alertId, privateAlertId], member),
2014
+ ).rejects.toThrow(NO_PERMISSION_TO_ACKNOWLEDGE_MESSAGE);
2015
+
2016
+ /*
2017
+ * Read for this project only, another project's alert is not found, so
2018
+ * it is not one of the alerts to acknowledge: it can never be written.
2019
+ */
2020
+ expect(
2021
+ await validateAcknowledge([alertId, foreignAlertId], member),
2022
+ ).toEqual({
2023
+ acknowledgedAlertStateId: alertStates.acknowledged.toString(),
2024
+ alertIdsToAcknowledge: [alertId.toString()],
2025
+ });
2026
+
2027
+ // Declaring from it is refused earlier, while the ids are checked.
2028
+ const refusal: unknown = await failureOf(() => {
2029
+ return IncidentService.create({
2030
+ data: newIncident(),
2031
+ miscDataProps: declaredFrom([alertId, foreignAlertId], true),
2032
+ props: member,
2033
+ });
2034
+ });
2035
+
2036
+ expect(refusal).toBeInstanceOf(BadDataException);
2037
+ expect((refusal as Error).message).toBe(NOT_VISIBLE_ALERTS_MESSAGE);
2038
+ expect(await incidentRows()).toEqual([]);
2039
+ expect(await incidentCounter()).toBe(0);
2040
+ expect(await linkCount()).toBe(0);
2041
+ expect(acknowledge).not.toHaveBeenCalled();
2042
+
2043
+ // Root is not asked: the ids were validated before this, as root.
2044
+ expect(
2045
+ await validateAcknowledge([alertId, privateAlertId], { isRoot: true }),
2046
+ ).toEqual({
2047
+ acknowledgedAlertStateId: alertStates.acknowledged.toString(),
2048
+ alertIdsToAcknowledge: [alertId.toString(), privateAlertId.toString()],
2049
+ });
2050
+
2051
+ // Checking writes nothing.
2052
+ expect(await timelineOf(privateAlertId)).toHaveLength(1);
2053
+ expect(await timelineOf(foreignAlertId)).toHaveLength(1);
2054
+ expect(await currentAlertStateOf(alertId)).toBe(
2055
+ alertStates.created.toString(),
2056
+ );
2057
+ });
2058
+
2059
+ test("an incident declared straight into Acknowledged with the project's switch on: the linked-alert sync acknowledges the alert, and the declaration leaves it to the sync", async () => {
2060
+ await database.query(
2061
+ `UPDATE "${schema}"."Project" SET "acknowledgeLinkedAlertsWhenIncidentAcknowledged" = true WHERE "_id" = $1`,
2062
+ [projectId.toString()],
2063
+ );
2064
+
2065
+ const userId: ObjectID = await seedUser();
2066
+ const alertId: ObjectID = await seedAlertThrough([alertStates.created]);
2067
+
2068
+ /*
2069
+ * The suite stubs the sync; this test runs the real one, held until
2070
+ * the declaration's own acknowledgement has decided, so the order the
2071
+ * two would race in is fixed: the sync always moves the alert last.
2072
+ */
2073
+ const sync: jest.SpyInstance = jest.spyOn(
2074
+ IncidentAlertService,
2075
+ "syncAlertWithLinkedIncidentState",
2076
+ );
2077
+ const syncForReal: (data: SyncArguments) => Promise<void> =
2078
+ Object.getPrototypeOf(
2079
+ IncidentAlertService,
2080
+ ).syncAlertWithLinkedIncidentState.bind(IncidentAlertService);
2081
+
2082
+ // Assigned by the executor, which runs straight away.
2083
+ let releaseSync: () => void = (): void => {
2084
+ // Replaced below.
2085
+ };
2086
+ const syncReleased: Promise<void> = new Promise<void>(
2087
+ (resolve: () => void) => {
2088
+ releaseSync = resolve;
2089
+ },
2090
+ );
2091
+
2092
+ sync.mockClear();
2093
+ sync.mockImplementation(async (data: SyncArguments): Promise<void> => {
2094
+ await syncReleased;
2095
+ return syncForReal(data);
2096
+ });
2097
+
2098
+ try {
2099
+ const incident: Incident = await IncidentService.create({
2100
+ data: newIncident({
2101
+ createdByUserId: userId,
2102
+ currentIncidentStateId: incidentStates.acknowledged,
2103
+ }),
2104
+ miscDataProps: declaredFrom([alertId], true),
2105
+ props: { isRoot: true },
2106
+ });
2107
+
2108
+ expect(incident.currentIncidentStateId?.toString()).toBe(
2109
+ incidentStates.acknowledged.toString(),
2110
+ );
2111
+
2112
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
2113
+
2114
+ expect(ids(result.leftToLinkedAlertSyncAlertIds)).toEqual([
2115
+ alertId.toString(),
2116
+ ]);
2117
+ expect(result.acknowledgedAlertIds).toEqual([]);
2118
+ expect(result.alreadyAcknowledgedAlertIds).toEqual([]);
2119
+ expect(result.failed).toEqual([]);
2120
+
2121
+ // The declaration wrote nothing on the alert.
2122
+ expect(await timelineOf(alertId)).toHaveLength(1);
2123
+ expect(await currentAlertStateOf(alertId)).toBe(
2124
+ alertStates.created.toString(),
2125
+ );
2126
+
2127
+ expect(sync).toHaveBeenCalledTimes(1);
2128
+ releaseSync();
2129
+ await sync.mock.results[0]!.value;
2130
+
2131
+ // The sync moved it, once.
2132
+ expect(await currentAlertStateOf(alertId)).toBe(
2133
+ alertStates.acknowledged.toString(),
2134
+ );
2135
+
2136
+ const timeline: Array<TimelineRow> = await timelineOf(alertId);
2137
+ expect(
2138
+ timeline.map((row: TimelineRow): string => {
2139
+ return row.alertStateId;
2140
+ }),
2141
+ ).toEqual([
2142
+ alertStates.created.toString(),
2143
+ alertStates.acknowledged.toString(),
2144
+ ]);
2145
+ expect(timeline[1]!.rootCause).toBe(
2146
+ "Acknowledged because linked Incident #1 was acknowledged.",
2147
+ );
2148
+ expect(stateChangeFeedItems()).toHaveLength(1);
2149
+ } finally {
2150
+ releaseSync();
2151
+ sync.mockResolvedValue(undefined);
2152
+ }
2153
+ });
2154
+
2155
+ /*
2156
+ * A responder may change only some alerts: a Viewer who is also an
2157
+ * AlertMember for one label. Only the alerts that will be written are
2158
+ * theirs to be allowed to change - one already resolved is left alone,
2159
+ * whatever its label.
2160
+ */
2161
+ describe("a member who may change only one label's alerts", () => {
2162
+ const DECLARED_CAUSE: string =
2163
+ "Acknowledged because Incident #1 was declared from this alert.";
2164
+
2165
+ let userId: ObjectID;
2166
+ let labelA: ObjectID;
2167
+ let labelB: ObjectID;
2168
+ let member: DatabaseCommonInteractionProps;
2169
+
2170
+ beforeEach(async () => {
2171
+ userId = await seedUser();
2172
+ labelA = await seedLabel("Payments");
2173
+ labelB = await seedLabel("Search");
2174
+ member = labelScopedMemberProps(userId, labelA);
2175
+ });
2176
+
2177
+ async function timelineRowCount(): Promise<number> {
2178
+ const rows: Array<{ count: string }> = await database.query(
2179
+ `SELECT count(*)::text AS "count" FROM "${schema}"."AlertStateTimeline"`,
2180
+ );
2181
+ return Number(rows[0]!.count);
2182
+ }
2183
+
2184
+ // The member's own right to change these alerts' states, on its own.
2185
+ function checkMayChange(alertIds: Array<ObjectID>): Promise<void> {
2186
+ return AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
2187
+ projectId: projectId,
2188
+ alertIds: alertIds,
2189
+ props: member,
2190
+ });
2191
+ }
2192
+
2193
+ test("declaring from A1 (label A, Created) and B1 (label B, Resolved) with acknowledgeAlertsToLink goes through, and only A1 is acknowledged", async () => {
2194
+ const alertA1: ObjectID = await labelAlert(
2195
+ await seedAlertThrough([alertStates.created]),
2196
+ labelA,
2197
+ );
2198
+ const alertB1: ObjectID = await labelAlert(
2199
+ await seedAlertThrough([alertStates.created, alertStates.resolved]),
2200
+ labelB,
2201
+ );
2202
+
2203
+ const resolvedTimeline: Array<TimelineRow> = await timelineOf(alertB1);
2204
+
2205
+ // The member may change A1, and not B1.
2206
+ await expect(checkMayChange([alertA1])).resolves.toBeUndefined();
2207
+ await expect(checkMayChange([alertB1])).rejects.toThrow(
2208
+ NotAuthorizedException,
2209
+ );
2210
+
2211
+ // B1 is not one to acknowledge, so it is not checked, nor written.
2212
+ expect(await validateAcknowledge([alertA1, alertB1], member)).toEqual({
2213
+ acknowledgedAlertStateId: alertStates.acknowledged.toString(),
2214
+ alertIdsToAcknowledge: [alertA1.toString()],
2215
+ });
2216
+
2217
+ const incident: Incident = await IncidentService.create({
2218
+ data: newIncident(),
2219
+ miscDataProps: declaredFrom([alertA1, alertB1], true),
2220
+ props: member,
2221
+ });
2222
+
2223
+ expect(incident.incidentNumber).toBe(1);
2224
+
2225
+ await waitForAlertState(alertA1, alertStates.acknowledged);
2226
+
2227
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
2228
+
2229
+ expect(ids(result.acknowledgedAlertIds)).toEqual([alertA1.toString()]);
2230
+ expect(result.alreadyAcknowledgedAlertIds).toEqual([]);
2231
+ expect(result.leftToLinkedAlertSyncAlertIds).toEqual([]);
2232
+ expect(result.failed).toEqual([]);
2233
+
2234
+ const args: AcknowledgeArguments = acknowledgementArguments();
2235
+ expect(ids(args.alertIds)).toEqual([alertA1.toString()]);
2236
+ expect(ids(args.linkedAlertIds)).toEqual([
2237
+ alertA1.toString(),
2238
+ alertB1.toString(),
2239
+ ]);
2240
+ expect(String(args.acknowledgedByUserId)).toBe(userId.toString());
2241
+
2242
+ // Both are linked, by the member.
2243
+ expect(await linkRows()).toEqual([
2244
+ { _id: expect.any(String), createdByUserId: userId.toString() },
2245
+ { _id: expect.any(String), createdByUserId: userId.toString() },
2246
+ ]);
2247
+
2248
+ const timeline: Array<TimelineRow> = await timelineOf(alertA1);
2249
+ expect(timelineStates(timeline)).toEqual([
2250
+ alertStates.created.toString(),
2251
+ alertStates.acknowledged.toString(),
2252
+ ]);
2253
+ expect(timeline[1]).toMatchObject({
2254
+ createdByUserId: userId.toString(),
2255
+ rootCause: DECLARED_CAUSE,
2256
+ endsAt: null,
2257
+ });
2258
+
2259
+ // B1 is untouched.
2260
+ expect(await currentAlertStateOf(alertB1)).toBe(
2261
+ alertStates.resolved.toString(),
2262
+ );
2263
+ expect(await timelineOf(alertB1)).toEqual(resolvedTimeline);
2264
+
2265
+ expect(
2266
+ stateChangeFeedItems().map((item: AlertFeedItem): string => {
2267
+ return item.alertId.toString();
2268
+ }),
2269
+ ).toEqual([alertA1.toString()]);
2270
+ });
2271
+
2272
+ test("declaring from A1 and B2 (label B, Created) with acknowledgeAlertsToLink is refused before the incident, its number or its links exist", async () => {
2273
+ const alertA1: ObjectID = await labelAlert(
2274
+ await seedAlertThrough([alertStates.created]),
2275
+ labelA,
2276
+ );
2277
+ const alertB2: ObjectID = await labelAlert(
2278
+ await seedAlertThrough([alertStates.created]),
2279
+ labelB,
2280
+ );
2281
+
2282
+ const refusal: unknown = await failureOf(() => {
2283
+ return IncidentService.create({
2284
+ data: newIncident(),
2285
+ miscDataProps: declaredFrom([alertA1, alertB2], true),
2286
+ props: member,
2287
+ });
2288
+ });
2289
+
2290
+ expect(refusal).toBeInstanceOf(BadDataException);
2291
+ expect((refusal as Error).message).toBe(
2292
+ NO_PERMISSION_TO_ACKNOWLEDGE_MESSAGE,
2293
+ );
2294
+
2295
+ expect(await incidentRows()).toEqual([]);
2296
+ expect(await incidentCounter()).toBe(0);
2297
+ expect(await linkCount()).toBe(0);
2298
+ expect(acknowledge).not.toHaveBeenCalled();
2299
+
2300
+ for (const alertId of [alertA1, alertB2]) {
2301
+ expect(await currentAlertStateOf(alertId)).toBe(
2302
+ alertStates.created.toString(),
2303
+ );
2304
+ expect(await timelineOf(alertId)).toHaveLength(1);
2305
+ }
2306
+
2307
+ expect(stateChangeFeedItems()).toEqual([]);
2308
+
2309
+ /*
2310
+ * Not a refusal to link: declared without asking to acknowledge, the
2311
+ * member may declare from both - and it takes number 1.
2312
+ */
2313
+ const incident: Incident = await IncidentService.create({
2314
+ data: newIncident(),
2315
+ miscDataProps: declaredFrom([alertA1, alertB2]),
2316
+ props: member,
2317
+ });
2318
+
2319
+ expect(incident.incidentNumber).toBe(1);
2320
+ expect(await linkCount()).toBe(2);
2321
+ expect(acknowledge).not.toHaveBeenCalled();
2322
+ expect(await currentAlertStateOf(alertA1)).toBe(
2323
+ alertStates.created.toString(),
2324
+ );
2325
+ expect(await currentAlertStateOf(alertB2)).toBe(
2326
+ alertStates.created.toString(),
2327
+ );
2328
+ });
2329
+
2330
+ test("every alert already acknowledged or resolved: the declaration goes through with nothing to acknowledge, even outside the member's labels, and writes no timeline row", async () => {
2331
+ const acknowledgedA: ObjectID = await labelAlert(
2332
+ await seedAlertThrough([
2333
+ alertStates.created,
2334
+ alertStates.acknowledged,
2335
+ ]),
2336
+ labelA,
2337
+ );
2338
+ const acknowledgedB: ObjectID = await labelAlert(
2339
+ await seedAlertThrough([
2340
+ alertStates.created,
2341
+ alertStates.acknowledged,
2342
+ ]),
2343
+ labelB,
2344
+ );
2345
+ const resolvedB: ObjectID = await labelAlert(
2346
+ await seedAlertThrough([alertStates.created, alertStates.resolved]),
2347
+ labelB,
2348
+ );
2349
+
2350
+ const declaredAlertIds: Array<ObjectID> = [
2351
+ acknowledgedA,
2352
+ acknowledgedB,
2353
+ resolvedB,
2354
+ ];
2355
+
2356
+ const timelinesBefore: Array<Array<TimelineRow>> = [];
2357
+ for (const alertId of declaredAlertIds) {
2358
+ timelinesBefore.push(await timelineOf(alertId));
2359
+ }
2360
+
2361
+ expect(await timelineRowCount()).toBe(6);
2362
+
2363
+ // The member may not change the label-B alerts.
2364
+ await expect(checkMayChange([acknowledgedA])).resolves.toBeUndefined();
2365
+ await expect(checkMayChange([acknowledgedB])).rejects.toThrow(
2366
+ NotAuthorizedException,
2367
+ );
2368
+ await expect(checkMayChange([resolvedB])).rejects.toThrow(
2369
+ NotAuthorizedException,
2370
+ );
2371
+
2372
+ // Nothing to acknowledge, so nothing the member must be allowed.
2373
+ expect(await validateAcknowledge(declaredAlertIds, member)).toEqual({
2374
+ acknowledgedAlertStateId: alertStates.acknowledged.toString(),
2375
+ alertIdsToAcknowledge: [],
2376
+ });
2377
+
2378
+ const incident: Incident = await IncidentService.create({
2379
+ data: newIncident(),
2380
+ miscDataProps: declaredFrom(declaredAlertIds, true),
2381
+ props: member,
2382
+ });
2383
+
2384
+ expect(incident.incidentNumber).toBe(1);
2385
+ expect(await linkCount()).toBe(3);
2386
+
2387
+ /*
2388
+ * The acknowledgement is started before create() returns when there
2389
+ * is anything to acknowledge, so not having started by now is final.
2390
+ */
2391
+ expect(acknowledge).not.toHaveBeenCalled();
2392
+
2393
+ expect(await timelineRowCount()).toBe(6);
2394
+
2395
+ for (let index: number = 0; index < declaredAlertIds.length; index++) {
2396
+ expect(await timelineOf(declaredAlertIds[index]!)).toEqual(
2397
+ timelinesBefore[index],
2398
+ );
2399
+ }
2400
+
2401
+ expect(await currentAlertStateOf(acknowledgedA)).toBe(
2402
+ alertStates.acknowledged.toString(),
2403
+ );
2404
+ expect(await currentAlertStateOf(acknowledgedB)).toBe(
2405
+ alertStates.acknowledged.toString(),
2406
+ );
2407
+ expect(await currentAlertStateOf(resolvedB)).toBe(
2408
+ alertStates.resolved.toString(),
2409
+ );
2410
+ expect(stateChangeFeedItems()).toEqual([]);
2411
+ });
2412
+ });
2413
+
2414
+ test("seven alerts declared at once are all acknowledged, five written at a time, each with one new timeline row", async () => {
2415
+ const userId: ObjectID = await seedUser();
2416
+
2417
+ const alertIds: Array<ObjectID> = [];
2418
+ for (let index: number = 0; index < 7; index++) {
2419
+ alertIds.push(await seedAlertThrough([alertStates.created]));
2420
+ }
2421
+
2422
+ type ChangeAlertStateArguments = Parameters<
2423
+ typeof AlertService.changeAlertState
2424
+ >[0];
2425
+
2426
+ // The real state change, recording when each write starts and ends.
2427
+ const changeAlertStateForReal: (
2428
+ data: ChangeAlertStateArguments,
2429
+ ) => Promise<void> = AlertService.changeAlertState.bind(AlertService);
2430
+
2431
+ const events: Array<string> = [];
2432
+ let inFlight: number = 0;
2433
+ let mostInFlight: number = 0;
2434
+
2435
+ const changeAlertState: jest.SpyInstance = jest
2436
+ .spyOn(AlertService, "changeAlertState")
2437
+ .mockImplementation(
2438
+ async (data: ChangeAlertStateArguments): Promise<void> => {
2439
+ events.push(`start ${data.alertId.toString()}`);
2440
+ inFlight++;
2441
+ mostInFlight = Math.max(mostInFlight, inFlight);
2442
+
2443
+ try {
2444
+ return await changeAlertStateForReal(data);
2445
+ } finally {
2446
+ inFlight--;
2447
+ events.push(`end ${data.alertId.toString()}`);
2448
+ }
2449
+ },
2450
+ );
2451
+
2452
+ try {
2453
+ const incident: Incident = await IncidentService.create({
2454
+ data: newIncident({ createdByUserId: userId }),
2455
+ miscDataProps: declaredFrom(alertIds, true),
2456
+ props: { isRoot: true },
2457
+ });
2458
+
2459
+ expect(incident.incidentNumber).toBe(1);
2460
+
2461
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledgement();
2462
+
2463
+ // All seven, in the order they were declared from.
2464
+ expect(ids(result.acknowledgedAlertIds)).toEqual(ids(alertIds));
2465
+ expect(result.alreadyAcknowledgedAlertIds).toEqual([]);
2466
+ expect(result.leftToLinkedAlertSyncAlertIds).toEqual([]);
2467
+ expect(result.failed).toEqual([]);
2468
+ expect(ids(acknowledgementArguments().alertIds)).toEqual(ids(alertIds));
2469
+
2470
+ // Five written together, the last two once those five had finished.
2471
+ const firstBatch: Array<string> = ids(alertIds.slice(0, 5));
2472
+ const secondBatch: Array<string> = ids(alertIds.slice(5));
2473
+
2474
+ function eventsFor(kind: string, batch: Array<string>): Array<string> {
2475
+ return batch.map((alertId: string): string => {
2476
+ return `${kind} ${alertId}`;
2477
+ });
2478
+ }
2479
+
2480
+ expect(changeAlertState).toHaveBeenCalledTimes(7);
2481
+ expect(mostInFlight).toBe(5);
2482
+ expect(events).toHaveLength(14);
2483
+ expect(events.slice(0, 5)).toEqual(eventsFor("start", firstBatch));
2484
+ expect([...events.slice(5, 10)].sort()).toEqual(
2485
+ eventsFor("end", firstBatch).sort(),
2486
+ );
2487
+ expect(events.slice(10, 12)).toEqual(eventsFor("start", secondBatch));
2488
+ expect([...events.slice(12)].sort()).toEqual(
2489
+ eventsFor("end", secondBatch).sort(),
2490
+ );
2491
+
2492
+ for (const alertId of alertIds) {
2493
+ expect(await currentAlertStateOf(alertId)).toBe(
2494
+ alertStates.acknowledged.toString(),
2495
+ );
2496
+
2497
+ const timeline: Array<TimelineRow> = await timelineOf(alertId);
2498
+ expect(timelineStates(timeline)).toEqual([
2499
+ alertStates.created.toString(),
2500
+ alertStates.acknowledged.toString(),
2501
+ ]);
2502
+ expect(timeline[1]).toMatchObject({
2503
+ createdByUserId: userId.toString(),
2504
+ rootCause:
2505
+ "Acknowledged because Incident #1 was declared from this alert.",
2506
+ endsAt: null,
2507
+ });
2508
+ expect(timeline[0]!.endsAt?.getTime()).toBe(
2509
+ timeline[1]!.startsAt.getTime(),
2510
+ );
2511
+ }
2512
+
2513
+ // One feed entry per alert, and every alert linked.
2514
+ expect(
2515
+ stateChangeFeedItems()
2516
+ .map((item: AlertFeedItem): string => {
2517
+ return item.alertId.toString();
2518
+ })
2519
+ .sort(),
2520
+ ).toEqual([...ids(alertIds)].sort());
2521
+ expect(await linkCount()).toBe(7);
2522
+ } finally {
2523
+ changeAlertState.mockRestore();
2524
+ }
2525
+ });
2526
+ });
903
2527
  });