@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
@@ -0,0 +1,2630 @@
1
+ import AlertService from "../../../Server/Services/AlertService";
2
+ import AlertStateService from "../../../Server/Services/AlertStateService";
3
+ import AlertStateTimelineService from "../../../Server/Services/AlertStateTimelineService";
4
+ import IncidentAlertService, {
5
+ AcknowledgeDeclaredAlertsResult,
6
+ AlertsToAcknowledgeOnDeclare,
7
+ getDeclaredAlertAcknowledgementCause,
8
+ } from "../../../Server/Services/IncidentAlertService";
9
+ import IncidentService from "../../../Server/Services/IncidentService";
10
+ import IncidentStateService from "../../../Server/Services/IncidentStateService";
11
+ import ProjectService from "../../../Server/Services/ProjectService";
12
+ import AlertStateChangeAuthorization from "../../../Server/Utils/Alert/AlertStateChangeAuthorization";
13
+ import logger from "../../../Server/Utils/Logger";
14
+ import Alert from "../../../Models/DatabaseModels/Alert";
15
+ import AlertState from "../../../Models/DatabaseModels/AlertState";
16
+ import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
17
+ import Incident from "../../../Models/DatabaseModels/Incident";
18
+ import IncidentAlert from "../../../Models/DatabaseModels/IncidentAlert";
19
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
20
+ import Project from "../../../Models/DatabaseModels/Project";
21
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
22
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
23
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
24
+ import BadDataException from "../../../Types/Exception/BadDataException";
25
+ import NotAuthenticatedException from "../../../Types/Exception/NotAuthenticatedException";
26
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
27
+ import PaymentRequiredException from "../../../Types/Exception/PaymentRequiredException";
28
+ import {
29
+ INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY,
30
+ INCIDENT_ALERT_IDS_TO_LINK_KEY,
31
+ } from "../../../Types/Incident/IncidentAlertLink";
32
+ import { JSONObject } from "../../../Types/JSON";
33
+ import ObjectID from "../../../Types/ObjectID";
34
+ import UserType from "../../../Types/UserType";
35
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
36
+ import { FindOperator } from "typeorm";
37
+
38
+ /*
39
+ * Declaring an incident from alerts can also acknowledge those alerts, which
40
+ * is what stops their on-call escalation. Pinned here, against stubbed
41
+ * neighbours and no database:
42
+ *
43
+ * - validateAcknowledgeAlertsForNewIncident, the check IncidentService's
44
+ * onBeforeCreate runs before the incident number is taken: what the
45
+ * miscDataProps flag may be, what it needs, which alerts it settles on
46
+ * acknowledging (only those not acknowledged yet, in the order given) and
47
+ * that the caller is checked for exactly those - never for an alert that
48
+ * will be left alone;
49
+ * - acknowledgeAlertsDeclaredWithIncident, the fire-and-forget write after
50
+ * the links exist: who the change is credited to, what "why" it carries
51
+ * (never naming a private incident), which alerts it leaves alone (already
52
+ * acknowledged or later, or owned by the linked-alert sync), that it
53
+ * writes at most five alerts at a time, batch after batch, how each
54
+ * outcome is classified by reading the alert back with one findOneBy (not
55
+ * AlertService.isAlertAcknowledged), and that it never throws;
56
+ * - getDeclaredAlertAcknowledgementCause, the "why" itself;
57
+ * - AlertService.changeAlertState's new createdByUserId.
58
+ */
59
+
60
+ const PROJECT_ID: ObjectID = new ObjectID(
61
+ "0194c3a9-0000-4000-8000-000000000001",
62
+ );
63
+ const INCIDENT_ID: ObjectID = new ObjectID(
64
+ "0194c3a9-0000-4000-8000-0000000000a1",
65
+ );
66
+ const OTHER_INCIDENT_ID: ObjectID = new ObjectID(
67
+ "0194c3a9-0000-4000-8000-0000000000a2",
68
+ );
69
+ const USER_ID: ObjectID = new ObjectID("0194c3a9-0000-4000-8000-0000000000e1");
70
+
71
+ // Alert ids, named after the state each starts in.
72
+ const CREATED_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b1";
73
+ const SECOND_CREATED_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b2";
74
+ const THIRD_CREATED_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b3";
75
+ const ACKNOWLEDGED_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b4";
76
+ const INVESTIGATING_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b5";
77
+ const RESOLVED_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b6";
78
+ // Not in this project: the project-scoped read never returns it.
79
+ const FOREIGN_ALERT: string = "0194c3a9-0000-4000-8000-0000000000b7";
80
+
81
+ // Writes in flight at once (DECLARED_ALERT_ACKNOWLEDGE_CONCURRENCY).
82
+ const BATCH_SIZE: number = 5;
83
+
84
+ const PERMISSION_MESSAGE: string =
85
+ "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.";
86
+ const NO_ACKNOWLEDGED_STATE_ON_CREATE_MESSAGE: string =
87
+ "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.";
88
+ const NO_ACKNOWLEDGED_STATE_MESSAGE: string =
89
+ "This project has no Acknowledged alert state, so the alerts could not be acknowledged.";
90
+ const NOT_FOUND_MESSAGE: string =
91
+ "The alert could not be found in this project.";
92
+ const DID_NOT_CHANGE_MESSAGE: string =
93
+ "The alert's state did not change to Acknowledged.";
94
+ const PRIVATE_CAUSE: string =
95
+ "Acknowledged because a private incident was declared from this alert.";
96
+
97
+ interface StateRow {
98
+ id: string;
99
+ name: string;
100
+ order: number;
101
+ isAcknowledged?: boolean;
102
+ isResolved?: boolean;
103
+ }
104
+
105
+ // Incident states. "Monitoring" is a custom state between ack and resolve.
106
+ const INCIDENT_CREATED: StateRow = {
107
+ id: "0194c3a9-0000-4000-8000-0000000000c1",
108
+ name: "Created",
109
+ order: 1,
110
+ };
111
+ const INCIDENT_ACKNOWLEDGED: StateRow = {
112
+ id: "0194c3a9-0000-4000-8000-0000000000c2",
113
+ name: "Acknowledged",
114
+ order: 2,
115
+ isAcknowledged: true,
116
+ };
117
+ const INCIDENT_MONITORING: StateRow = {
118
+ id: "0194c3a9-0000-4000-8000-0000000000c3",
119
+ name: "Monitoring",
120
+ order: 3,
121
+ };
122
+ const INCIDENT_RESOLVED: StateRow = {
123
+ id: "0194c3a9-0000-4000-8000-0000000000c4",
124
+ name: "Resolved",
125
+ order: 4,
126
+ isResolved: true,
127
+ };
128
+
129
+ // Alert states. "Investigating" is a custom state between ack and resolve.
130
+ const ALERT_CREATED: StateRow = {
131
+ id: "0194c3a9-0000-4000-8000-0000000000d1",
132
+ name: "Created",
133
+ order: 1,
134
+ };
135
+ const ALERT_ACKNOWLEDGED: StateRow = {
136
+ id: "0194c3a9-0000-4000-8000-0000000000d2",
137
+ name: "Acknowledged",
138
+ order: 2,
139
+ isAcknowledged: true,
140
+ };
141
+ const ALERT_INVESTIGATING: StateRow = {
142
+ id: "0194c3a9-0000-4000-8000-0000000000d3",
143
+ name: "Investigating",
144
+ order: 3,
145
+ };
146
+ const ALERT_RESOLVED: StateRow = {
147
+ id: "0194c3a9-0000-4000-8000-0000000000d4",
148
+ name: "Resolved",
149
+ order: 4,
150
+ isResolved: true,
151
+ };
152
+
153
+ interface IncidentRow {
154
+ incidentNumber: number | undefined;
155
+ incidentNumberWithPrefix: string | undefined;
156
+ title: string;
157
+ isPrivate: boolean;
158
+ stateId: string;
159
+ }
160
+
161
+ interface Tables {
162
+ switches: { acknowledge: boolean; resolve: boolean };
163
+ incidentStates: Array<StateRow>;
164
+ alertStates: Array<StateRow>;
165
+ // The incident being declared; null when it cannot be read.
166
+ incident: IncidentRow | null;
167
+ // Other incidents: id -> current state id.
168
+ otherIncidents: Map<string, string>;
169
+ // alert id -> current state id (this project's alerts only).
170
+ alerts: Map<string, string>;
171
+ links: Array<{ incidentId: string; alertId: string }>;
172
+ }
173
+
174
+ let tables: Tables;
175
+
176
+ function freshTables(): Tables {
177
+ return {
178
+ switches: { acknowledge: false, resolve: false },
179
+ incidentStates: [
180
+ INCIDENT_CREATED,
181
+ INCIDENT_ACKNOWLEDGED,
182
+ INCIDENT_MONITORING,
183
+ INCIDENT_RESOLVED,
184
+ ],
185
+ alertStates: [
186
+ ALERT_CREATED,
187
+ ALERT_ACKNOWLEDGED,
188
+ ALERT_INVESTIGATING,
189
+ ALERT_RESOLVED,
190
+ ],
191
+ incident: {
192
+ incidentNumber: 42,
193
+ incidentNumberWithPrefix: "INC-42",
194
+ title: "Payroll database is down",
195
+ isPrivate: false,
196
+ stateId: INCIDENT_CREATED.id,
197
+ },
198
+ otherIncidents: new Map(),
199
+ alerts: new Map([
200
+ [CREATED_ALERT, ALERT_CREATED.id],
201
+ [SECOND_CREATED_ALERT, ALERT_CREATED.id],
202
+ [THIRD_CREATED_ALERT, ALERT_CREATED.id],
203
+ [ACKNOWLEDGED_ALERT, ALERT_ACKNOWLEDGED.id],
204
+ [INVESTIGATING_ALERT, ALERT_INVESTIGATING.id],
205
+ [RESOLVED_ALERT, ALERT_RESOLVED.id],
206
+ ]),
207
+ links: [],
208
+ };
209
+ }
210
+
211
+ // The ids a QueryHelper.any(...) filter matches, or the one plain value.
212
+ function idsIn(value: unknown): Array<string> | null {
213
+ if (value === undefined) {
214
+ return null;
215
+ }
216
+
217
+ if (value instanceof FindOperator) {
218
+ const parameters: Array<unknown> = Object.values(
219
+ value.objectLiteralParameters || {},
220
+ );
221
+ return ((parameters[0] as Array<string>) || []).map((id: string) => {
222
+ return id.toLowerCase();
223
+ });
224
+ }
225
+
226
+ return [String(value).toLowerCase()];
227
+ }
228
+
229
+ function matches(value: string, filter: Array<string> | null): boolean {
230
+ return filter === null || filter.includes(value.toLowerCase());
231
+ }
232
+
233
+ function stateRowById(id: string): StateRow | undefined {
234
+ return tables.alertStates.find((row: StateRow) => {
235
+ return row.id === id;
236
+ });
237
+ }
238
+
239
+ function alertStateModel(row: StateRow): AlertState {
240
+ const state: AlertState = new AlertState();
241
+ state._id = row.id;
242
+ state.name = row.name;
243
+ state.order = row.order;
244
+ state.isAcknowledgedState = Boolean(row.isAcknowledged);
245
+ state.isResolvedState = Boolean(row.isResolved);
246
+ state.isCreatedState = row.order === 1;
247
+ return state;
248
+ }
249
+
250
+ function incidentStateModel(row: StateRow): IncidentState {
251
+ const state: IncidentState = new IncidentState();
252
+ state._id = row.id;
253
+ state.name = row.name;
254
+ state.order = row.order;
255
+ state.isAcknowledgedState = Boolean(row.isAcknowledged);
256
+ state.isResolvedState = Boolean(row.isResolved);
257
+ state.isCreatedState = row.order === 1;
258
+ return state;
259
+ }
260
+
261
+ function acknowledgedAlertStateRow(): StateRow | undefined {
262
+ return tables.alertStates.find((row: StateRow) => {
263
+ return row.isAcknowledged;
264
+ });
265
+ }
266
+
267
+ // Every id in a result list, lower-cased, in order.
268
+ function ids(list: Array<ObjectID>): Array<string> {
269
+ return list.map((id: ObjectID) => {
270
+ return id.toString().toLowerCase();
271
+ });
272
+ }
273
+
274
+ interface Outcome {
275
+ acknowledged: Array<string>;
276
+ alreadyAcknowledged: Array<string>;
277
+ leftToSync: Array<string>;
278
+ failed: Array<[string, string]>;
279
+ }
280
+
281
+ function outcome(result: AcknowledgeDeclaredAlertsResult): Outcome {
282
+ return {
283
+ acknowledged: ids(result.acknowledgedAlertIds),
284
+ alreadyAcknowledged: ids(result.alreadyAcknowledgedAlertIds),
285
+ leftToSync: ids(result.leftToLinkedAlertSyncAlertIds),
286
+ failed: result.failed.map(
287
+ (failure: { alertId: ObjectID; message: string }): [string, string] => {
288
+ return [failure.alertId.toString().toLowerCase(), failure.message];
289
+ },
290
+ ),
291
+ };
292
+ }
293
+
294
+ function emptyOutcome(): Outcome {
295
+ return {
296
+ acknowledged: [],
297
+ alreadyAcknowledged: [],
298
+ leftToSync: [],
299
+ failed: [],
300
+ };
301
+ }
302
+
303
+ interface StateChange {
304
+ projectId: ObjectID;
305
+ alertId: ObjectID;
306
+ alertStateId: ObjectID;
307
+ notifyOwners: boolean;
308
+ rootCause: string | undefined;
309
+ stateChangeLog: JSONObject | undefined;
310
+ createdByUserId?: ObjectID | undefined;
311
+ props: DatabaseCommonInteractionProps | undefined;
312
+ }
313
+
314
+ /*
315
+ * What a state change does to the in-memory alert. The default is what the
316
+ * real write does: the alert's current state moves to the one asked for.
317
+ */
318
+ type WriteBehaviour = (change: StateChange) => Promise<void>;
319
+
320
+ function writeThrough(change: StateChange): Promise<void> {
321
+ tables.alerts.set(
322
+ change.alertId.toString().toLowerCase(),
323
+ change.alertStateId.toString(),
324
+ );
325
+ return Promise.resolve();
326
+ }
327
+
328
+ let writeBehaviours: Map<string, WriteBehaviour>;
329
+ let changes: Array<StateChange>;
330
+
331
+ let changeAlertState: jest.SpyInstance;
332
+ let isAlertAcknowledged: jest.SpyInstance;
333
+ let readBack: jest.SpyInstance;
334
+ let acknowledgedStateLookup: jest.SpyInstance;
335
+ let incidentLookup: jest.SpyInstance;
336
+ let alertLookup: jest.SpyInstance;
337
+ let projectLookup: jest.SpyInstance;
338
+ let incidentStateLookup: jest.SpyInstance;
339
+ let alertStatesLookup: jest.SpyInstance;
340
+ let linkLookup: jest.SpyInstance;
341
+ let otherIncidentLookup: jest.SpyInstance;
342
+ let authorization: jest.SpyInstance;
343
+ let errorLog: jest.SpyInstance;
344
+
345
+ beforeEach(() => {
346
+ tables = freshTables();
347
+ changes = [];
348
+ writeBehaviours = new Map();
349
+
350
+ errorLog = jest.spyOn(logger, "error").mockImplementation((() => {
351
+ // quiet
352
+ }) as never);
353
+
354
+ acknowledgedStateLookup = jest
355
+ .spyOn(AlertStateService, "findOneBy")
356
+ .mockImplementation((async (): Promise<AlertState | null> => {
357
+ const row: StateRow | undefined = acknowledgedAlertStateRow();
358
+
359
+ if (!row) {
360
+ return null;
361
+ }
362
+
363
+ const state: AlertState = new AlertState();
364
+ state._id = row.id;
365
+ state.order = row.order;
366
+ return state;
367
+ }) as never);
368
+
369
+ authorization = jest
370
+ .spyOn(AlertStateChangeAuthorization, "assertCanChangeStateOfAlerts")
371
+ .mockResolvedValue(undefined as never);
372
+
373
+ incidentLookup = jest
374
+ .spyOn(IncidentService, "findOneById")
375
+ .mockImplementation((async (args: {
376
+ id: ObjectID;
377
+ }): Promise<Incident | null> => {
378
+ const row: IncidentRow | null = tables.incident;
379
+
380
+ if (!row || args.id.toString() !== INCIDENT_ID.toString()) {
381
+ return null;
382
+ }
383
+
384
+ const incident: Incident = new Incident();
385
+ incident._id = INCIDENT_ID.toString();
386
+ incident.projectId = PROJECT_ID;
387
+ incident.title = row.title;
388
+ incident.isPrivate = row.isPrivate;
389
+ incident.currentIncidentStateId = new ObjectID(row.stateId);
390
+
391
+ if (row.incidentNumber !== undefined) {
392
+ incident.incidentNumber = row.incidentNumber;
393
+ }
394
+
395
+ if (row.incidentNumberWithPrefix !== undefined) {
396
+ incident.incidentNumberWithPrefix = row.incidentNumberWithPrefix;
397
+ }
398
+
399
+ return incident;
400
+ }) as never);
401
+
402
+ alertLookup = jest
403
+ .spyOn(AlertService, "findBy")
404
+ .mockImplementation((async (args: {
405
+ query: { _id?: unknown; projectId?: unknown };
406
+ props: DatabaseCommonInteractionProps;
407
+ }): Promise<Array<Alert>> => {
408
+ expect(args.props).toEqual({ isRoot: true });
409
+ expect(String(args.query.projectId)).toBe(PROJECT_ID.toString());
410
+
411
+ const filter: Array<string> | null = idsIn(args.query._id);
412
+
413
+ return Array.from(tables.alerts.entries())
414
+ .filter(([id]: [string, string]) => {
415
+ return matches(id, filter);
416
+ })
417
+ .map(([id, stateId]: [string, string]) => {
418
+ const alert: Alert = new Alert();
419
+ alert._id = id;
420
+ alert.currentAlertStateId = new ObjectID(stateId);
421
+
422
+ const row: StateRow | undefined = stateRowById(stateId);
423
+
424
+ if (row) {
425
+ alert.currentAlertState = alertStateModel(row);
426
+ }
427
+
428
+ return alert;
429
+ });
430
+ }) as never);
431
+
432
+ projectLookup = jest
433
+ .spyOn(ProjectService, "findOneById")
434
+ .mockImplementation((async (): Promise<Project | null> => {
435
+ const project: Project = new Project();
436
+ project._id = PROJECT_ID.toString();
437
+ project.acknowledgeLinkedAlertsWhenIncidentAcknowledged =
438
+ tables.switches.acknowledge;
439
+ project.resolveLinkedAlertsWhenIncidentResolved = tables.switches.resolve;
440
+ return project;
441
+ }) as never);
442
+
443
+ incidentStateLookup = jest
444
+ .spyOn(IncidentStateService, "getAllIncidentStates")
445
+ .mockImplementation((async (): Promise<Array<IncidentState>> => {
446
+ return tables.incidentStates.map(incidentStateModel);
447
+ }) as never);
448
+
449
+ alertStatesLookup = jest
450
+ .spyOn(AlertStateService, "getAllAlertStates")
451
+ .mockImplementation((async (): Promise<Array<AlertState>> => {
452
+ return tables.alertStates.map(alertStateModel);
453
+ }) as never);
454
+
455
+ linkLookup = jest
456
+ .spyOn(IncidentAlertService, "findBy")
457
+ .mockImplementation((async (args: {
458
+ query: { incidentId?: unknown; alertId?: unknown };
459
+ }): Promise<Array<IncidentAlert>> => {
460
+ const incidentFilter: Array<string> | null = idsIn(args.query.incidentId);
461
+ const alertFilter: Array<string> | null = idsIn(args.query.alertId);
462
+
463
+ return tables.links
464
+ .filter((row: { incidentId: string; alertId: string }) => {
465
+ return (
466
+ matches(row.incidentId, incidentFilter) &&
467
+ matches(row.alertId, alertFilter)
468
+ );
469
+ })
470
+ .map((row: { incidentId: string; alertId: string }) => {
471
+ const model: IncidentAlert = new IncidentAlert();
472
+ model.incidentId = new ObjectID(row.incidentId);
473
+ model.alertId = new ObjectID(row.alertId);
474
+ model.projectId = PROJECT_ID;
475
+ return model;
476
+ });
477
+ }) as never);
478
+
479
+ otherIncidentLookup = jest
480
+ .spyOn(IncidentService, "findBy")
481
+ .mockImplementation((async (args: {
482
+ query: { _id?: unknown };
483
+ }): Promise<Array<Incident>> => {
484
+ const filter: Array<string> | null = idsIn(args.query._id);
485
+
486
+ return Array.from(tables.otherIncidents.entries())
487
+ .filter(([id]: [string, string]) => {
488
+ return matches(id, filter);
489
+ })
490
+ .map(([id, stateId]: [string, string]) => {
491
+ const incident: Incident = new Incident();
492
+ incident._id = id;
493
+ incident.currentIncidentStateId = new ObjectID(stateId);
494
+ return incident;
495
+ });
496
+ }) as never);
497
+
498
+ changeAlertState = jest
499
+ .spyOn(AlertService, "changeAlertState")
500
+ .mockImplementation((async (change: StateChange): Promise<void> => {
501
+ changes.push(change);
502
+
503
+ const behaviour: WriteBehaviour =
504
+ writeBehaviours.get(change.alertId.toString().toLowerCase()) ||
505
+ writeThrough;
506
+
507
+ await behaviour(change);
508
+ }) as never);
509
+
510
+ /*
511
+ * The read back after each write: the alert as it is now, found only in
512
+ * its project, with its current state (and so its order) when the state
513
+ * is one of the project's. Null when the alert is gone, as findOneBy is.
514
+ * The exact arguments are pinned in their own test.
515
+ */
516
+ readBack = jest
517
+ .spyOn(AlertService, "findOneBy")
518
+ .mockImplementation((async (args: {
519
+ query: { _id?: unknown; projectId?: unknown };
520
+ }): Promise<Alert | null> => {
521
+ if (String(args.query.projectId) !== PROJECT_ID.toString()) {
522
+ return null;
523
+ }
524
+
525
+ const alertId: string = String(args.query._id).toLowerCase();
526
+ const stateId: string | undefined = tables.alerts.get(alertId);
527
+
528
+ if (!stateId) {
529
+ return null;
530
+ }
531
+
532
+ const alert: Alert = new Alert();
533
+ alert._id = alertId;
534
+
535
+ const row: StateRow | undefined = stateRowById(stateId);
536
+
537
+ if (row) {
538
+ alert.currentAlertState = alertStateModel(row);
539
+ }
540
+
541
+ return alert;
542
+ }) as never);
543
+
544
+ // Not how a declared alert is read back any more: any call is a failure.
545
+ isAlertAcknowledged = jest
546
+ .spyOn(AlertService, "isAlertAcknowledged")
547
+ .mockRejectedValue(
548
+ new Error("isAlertAcknowledged must not be used here.") as never,
549
+ );
550
+ });
551
+
552
+ afterEach(() => {
553
+ const isAlertAcknowledgedCalls: number =
554
+ isAlertAcknowledged.mock.calls.length;
555
+
556
+ jest.restoreAllMocks();
557
+
558
+ // Declared alerts are read back with findOneBy, never isAlertAcknowledged.
559
+ expect(isAlertAcknowledgedCalls).toBe(0);
560
+ });
561
+
562
+ // Pass null for an API key, which acts for no user.
563
+ async function acknowledge(
564
+ alertIds: Array<string>,
565
+ options: {
566
+ linkedAlertIds?: Array<string>;
567
+ actor?: ObjectID | null;
568
+ } = {},
569
+ ): Promise<AcknowledgeDeclaredAlertsResult> {
570
+ const actor: ObjectID | null =
571
+ options.actor === undefined ? USER_ID : options.actor;
572
+
573
+ return IncidentAlertService.acknowledgeAlertsDeclaredWithIncident({
574
+ projectId: PROJECT_ID,
575
+ incidentId: INCIDENT_ID,
576
+ alertIds: alertIds.map((id: string) => {
577
+ return new ObjectID(id);
578
+ }),
579
+ linkedAlertIds: (options.linkedAlertIds || alertIds).map((id: string) => {
580
+ return new ObjectID(id);
581
+ }),
582
+ acknowledgedByUserId: actor || undefined,
583
+ });
584
+ }
585
+
586
+ // alert id -> state it was moved to (by name), in the order they were moved.
587
+ function moved(): Array<[string, string]> {
588
+ return changes.map((change: StateChange): [string, string] => {
589
+ const row: StateRow | undefined = stateRowById(
590
+ change.alertStateId.toString(),
591
+ );
592
+ return [
593
+ change.alertId.toString().toLowerCase(),
594
+ row?.name || change.alertStateId.toString(),
595
+ ];
596
+ });
597
+ }
598
+
599
+ function declaredInto(state: StateRow): void {
600
+ tables.incident!.stateId = state.id;
601
+ }
602
+
603
+ function setSwitches(acknowledge: boolean, resolve: boolean): void {
604
+ tables.switches = { acknowledge, resolve };
605
+ }
606
+
607
+ function link(alertId: string, incidentId: ObjectID = INCIDENT_ID): void {
608
+ tables.links.push({ incidentId: incidentId.toString(), alertId: alertId });
609
+ }
610
+
611
+ function perAlertErrorLogs(): Array<unknown> {
612
+ return errorLog.mock.calls.filter((call: Array<unknown>) => {
613
+ return String(call[0]).startsWith(
614
+ "IncidentAlertService could not acknowledge alert ",
615
+ );
616
+ });
617
+ }
618
+
619
+ // More Created alerts in this project (ids ...000000000101 upwards).
620
+ function addCreatedAlerts(count: number): Array<string> {
621
+ const alertIds: Array<string> = [];
622
+
623
+ for (let index: number = 0; index < count; index++) {
624
+ const alertId: string = `0194c3a9-0000-4000-8000-${(0x101 + index)
625
+ .toString(16)
626
+ .padStart(12, "0")}`;
627
+ tables.alerts.set(alertId, ALERT_CREATED.id);
628
+ alertIds.push(alertId);
629
+ }
630
+
631
+ return alertIds;
632
+ }
633
+
634
+ // The alert ids written, lower-cased, in the order the writes started.
635
+ function written(): Array<string> {
636
+ return changes.map((change: StateChange) => {
637
+ return change.alertId.toString().toLowerCase();
638
+ });
639
+ }
640
+
641
+ interface Deferred {
642
+ promise: Promise<void>;
643
+ resolve: () => void;
644
+ reject: (error: Error) => void;
645
+ }
646
+
647
+ function deferred(): Deferred {
648
+ let resolve: () => void = (): void => {
649
+ // replaced below
650
+ };
651
+ let reject: (error: Error) => void = (): void => {
652
+ // replaced below
653
+ };
654
+
655
+ const promise: Promise<void> = new Promise<void>(
656
+ (onResolve: () => void, onReject: (error: Error) => void) => {
657
+ resolve = onResolve;
658
+ reject = onReject;
659
+ },
660
+ );
661
+
662
+ return { promise, resolve, reject };
663
+ }
664
+
665
+ // Lets every chain that is not waiting on a held write run as far as it can.
666
+ async function flush(): Promise<void> {
667
+ for (let round: number = 0; round < 3; round++) {
668
+ await new Promise<void>((resolve: () => void) => {
669
+ setTimeout(resolve, 0);
670
+ });
671
+ }
672
+ }
673
+
674
+ /*
675
+ * Holds the writes of these alerts until the test lets them go: each write
676
+ * waits on its own gate, then moves the alert (or throws when the gate is
677
+ * rejected). Counts the writes in flight, and the most at once.
678
+ */
679
+ interface HeldWrites {
680
+ gates: Map<string, Deferred>;
681
+ inFlight: () => number;
682
+ maxInFlight: () => number;
683
+ }
684
+
685
+ function holdWrites(alertIds: Array<string>): HeldWrites {
686
+ const gates: Map<string, Deferred> = new Map();
687
+ const counts: { inFlight: number; maxInFlight: number } = {
688
+ inFlight: 0,
689
+ maxInFlight: 0,
690
+ };
691
+
692
+ for (const alertId of alertIds) {
693
+ const gate: Deferred = deferred();
694
+ gates.set(alertId, gate);
695
+
696
+ writeBehaviours.set(alertId, async (change: StateChange): Promise<void> => {
697
+ counts.inFlight++;
698
+ counts.maxInFlight = Math.max(counts.maxInFlight, counts.inFlight);
699
+
700
+ try {
701
+ await gate.promise;
702
+ await writeThrough(change);
703
+ } finally {
704
+ counts.inFlight--;
705
+ }
706
+ });
707
+ }
708
+
709
+ return {
710
+ gates: gates,
711
+ inFlight: (): number => {
712
+ return counts.inFlight;
713
+ },
714
+ maxInFlight: (): number => {
715
+ return counts.maxInFlight;
716
+ },
717
+ };
718
+ }
719
+
720
+ describe("getDeclaredAlertAcknowledgementCause", () => {
721
+ test("names the incident by its prefixed number", () => {
722
+ expect(
723
+ getDeclaredAlertAcknowledgementCause({
724
+ incidentNumber: "INC-42",
725
+ isIncidentPrivate: false,
726
+ }),
727
+ ).toBe(
728
+ "Acknowledged because Incident INC-42 was declared from this alert.",
729
+ );
730
+ });
731
+
732
+ test("names the incident by #number when the project has no prefix", () => {
733
+ expect(
734
+ getDeclaredAlertAcknowledgementCause({
735
+ incidentNumber: "#7",
736
+ isIncidentPrivate: false,
737
+ }),
738
+ ).toBe("Acknowledged because Incident #7 was declared from this alert.");
739
+ });
740
+
741
+ test("says just 'Incident' when the incident has no number", () => {
742
+ expect(
743
+ getDeclaredAlertAcknowledgementCause({
744
+ incidentNumber: "",
745
+ isIncidentPrivate: false,
746
+ }),
747
+ ).toBe("Acknowledged because Incident was declared from this alert.");
748
+ });
749
+
750
+ test("a private incident is not named at all, number or not", () => {
751
+ const numbered: string = getDeclaredAlertAcknowledgementCause({
752
+ incidentNumber: "INC-42",
753
+ isIncidentPrivate: true,
754
+ });
755
+ const unnumbered: string = getDeclaredAlertAcknowledgementCause({
756
+ incidentNumber: "",
757
+ isIncidentPrivate: true,
758
+ });
759
+
760
+ expect(numbered).toBe(PRIVATE_CAUSE);
761
+ expect(unnumbered).toBe(PRIVATE_CAUSE);
762
+ expect(numbered).not.toContain("INC-42");
763
+ expect(numbered).not.toContain("42");
764
+ });
765
+ });
766
+
767
+ describe("validateAcknowledgeAlertsForNewIncident", () => {
768
+ const ALERT_IDS: Array<ObjectID> = [
769
+ new ObjectID(CREATED_ALERT),
770
+ new ObjectID(SECOND_CREATED_ALERT),
771
+ ];
772
+
773
+ function memberProps(): DatabaseCommonInteractionProps {
774
+ return {
775
+ tenantId: PROJECT_ID,
776
+ userId: USER_ID,
777
+ userType: UserType.User,
778
+ };
779
+ }
780
+
781
+ function validate(
782
+ overrides: Partial<{
783
+ projectId: ObjectID | undefined;
784
+ acknowledgeAlerts: unknown;
785
+ alertIds: Array<ObjectID>;
786
+ props: DatabaseCommonInteractionProps;
787
+ }> = {},
788
+ ): Promise<AlertsToAcknowledgeOnDeclare | null> {
789
+ return IncidentAlertService.validateAcknowledgeAlertsForNewIncident({
790
+ projectId: PROJECT_ID,
791
+ acknowledgeAlerts: true,
792
+ alertIds: ALERT_IDS,
793
+ props: memberProps(),
794
+ ...overrides,
795
+ });
796
+ }
797
+
798
+ function alertIdsOf(alertIds: Array<string>): Array<ObjectID> {
799
+ return alertIds.map((alertId: string) => {
800
+ return new ObjectID(alertId);
801
+ });
802
+ }
803
+
804
+ /*
805
+ * The validator settled on acknowledging these alerts (lower-cased, in
806
+ * order) with the project's Acknowledged state - and nothing else.
807
+ */
808
+ function expectToAcknowledge(
809
+ result: AlertsToAcknowledgeOnDeclare | null,
810
+ alertIds: Array<string>,
811
+ ): void {
812
+ expect(result).not.toBeNull();
813
+ expect(Object.keys(result!).sort()).toEqual([
814
+ "acknowledgedAlertStateId",
815
+ "alertIdsToAcknowledge",
816
+ ]);
817
+ expect(result!.acknowledgedAlertStateId).toBeInstanceOf(ObjectID);
818
+ expect(result!.acknowledgedAlertStateId.toString()).toBe(
819
+ ALERT_ACKNOWLEDGED.id,
820
+ );
821
+ expect(Array.isArray(result!.alertIdsToAcknowledge)).toBe(true);
822
+
823
+ for (const alertId of result!.alertIdsToAcknowledge) {
824
+ expect(alertId).toBeInstanceOf(ObjectID);
825
+ }
826
+
827
+ expect(ids(result!.alertIdsToAcknowledge)).toEqual(alertIds);
828
+ }
829
+
830
+ // The alert ids the caller was checked for, lower-cased, in order.
831
+ function authorizedAlertIds(): Array<string> {
832
+ expect(authorization).toHaveBeenCalledTimes(1);
833
+ return ids(
834
+ (authorization.mock.calls[0]![0] as { alertIds: Array<ObjectID> })
835
+ .alertIds,
836
+ );
837
+ }
838
+
839
+ async function rejection(promise: Promise<unknown>): Promise<unknown> {
840
+ try {
841
+ await promise;
842
+ } catch (error) {
843
+ return error;
844
+ }
845
+
846
+ throw new Error("Expected the promise to reject, but it resolved.");
847
+ }
848
+
849
+ test("the key the flag travels under is 'acknowledgeAlertsToLink'", () => {
850
+ expect(INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY).toBe(
851
+ "acknowledgeAlertsToLink",
852
+ );
853
+ });
854
+
855
+ test("not asked for (absent, null or false): null, and nothing is read or checked", async () => {
856
+ for (const value of [undefined, null, false]) {
857
+ await expect(validate({ acknowledgeAlerts: value })).resolves.toBeNull();
858
+ }
859
+
860
+ // Even with no alerts and no project: not asking is always fine.
861
+ await expect(
862
+ validate({
863
+ acknowledgeAlerts: false,
864
+ alertIds: [],
865
+ projectId: undefined,
866
+ }),
867
+ ).resolves.toBeNull();
868
+
869
+ expect(acknowledgedStateLookup).not.toHaveBeenCalled();
870
+ expect(alertLookup).not.toHaveBeenCalled();
871
+ expect(authorization).not.toHaveBeenCalled();
872
+ });
873
+
874
+ test("anything but a boolean is refused, naming the key, before anything is read", async () => {
875
+ for (const value of ["true", "false", 1, 0, {}, [], "yes"]) {
876
+ const error: unknown = await rejection(
877
+ validate({ acknowledgeAlerts: value }),
878
+ );
879
+
880
+ expect(error).toBeInstanceOf(BadDataException);
881
+ expect((error as BadDataException).message).toBe(
882
+ "acknowledgeAlertsToLink must be true or false.",
883
+ );
884
+ }
885
+
886
+ expect(acknowledgedStateLookup).not.toHaveBeenCalled();
887
+ expect(alertLookup).not.toHaveBeenCalled();
888
+ expect(authorization).not.toHaveBeenCalled();
889
+ });
890
+
891
+ test("the type is checked before the alert ids", async () => {
892
+ const error: unknown = await rejection(
893
+ validate({ acknowledgeAlerts: "true", alertIds: [] }),
894
+ );
895
+
896
+ expect((error as BadDataException).message).toBe(
897
+ `${INCIDENT_ACKNOWLEDGE_ALERTS_TO_LINK_KEY} must be true or false.`,
898
+ );
899
+ });
900
+
901
+ test("true without alerts to link is refused, pointing at alertIdsToLink", async () => {
902
+ const error: unknown = await rejection(validate({ alertIds: [] }));
903
+
904
+ expect(error).toBeInstanceOf(BadDataException);
905
+ expect((error as BadDataException).message).toBe(
906
+ "acknowledgeAlertsToLink only applies when the incident is declared from alerts: send the alert ids in alertIdsToLink.",
907
+ );
908
+ expect((error as BadDataException).message).toContain(
909
+ INCIDENT_ALERT_IDS_TO_LINK_KEY,
910
+ );
911
+ expect(acknowledgedStateLookup).not.toHaveBeenCalled();
912
+ expect(alertLookup).not.toHaveBeenCalled();
913
+ expect(authorization).not.toHaveBeenCalled();
914
+ });
915
+
916
+ test("a missing project id is refused before anything is read", async () => {
917
+ const error: unknown = await rejection(validate({ projectId: undefined }));
918
+
919
+ expect(error).toBeInstanceOf(BadDataException);
920
+ expect((error as BadDataException).message).toBe(
921
+ "projectId is required to acknowledge alerts.",
922
+ );
923
+ expect(acknowledgedStateLookup).not.toHaveBeenCalled();
924
+ expect(alertLookup).not.toHaveBeenCalled();
925
+ expect(authorization).not.toHaveBeenCalled();
926
+ });
927
+
928
+ test("a project without an Acknowledged alert state is refused, and neither the alerts nor permissions are probed", async () => {
929
+ tables.alertStates = [ALERT_CREATED, ALERT_RESOLVED];
930
+
931
+ const error: unknown = await rejection(validate());
932
+
933
+ expect(error).toBeInstanceOf(BadDataException);
934
+ expect((error as BadDataException).message).toBe(
935
+ NO_ACKNOWLEDGED_STATE_ON_CREATE_MESSAGE,
936
+ );
937
+ expect((error as BadDataException).message).toContain(
938
+ "Declare the incident without acknowledging them",
939
+ );
940
+ expect(alertLookup).not.toHaveBeenCalled();
941
+ expect(authorization).not.toHaveBeenCalled();
942
+ });
943
+
944
+ test("an Acknowledged state row without an id counts as no state", async () => {
945
+ const idless: AlertState = new AlertState();
946
+ idless.order = ALERT_ACKNOWLEDGED.order;
947
+ acknowledgedStateLookup.mockResolvedValueOnce(idless as never);
948
+
949
+ const error: unknown = await rejection(validate());
950
+
951
+ expect(error).toBeInstanceOf(BadDataException);
952
+ expect((error as BadDataException).message).toBe(
953
+ NO_ACKNOWLEDGED_STATE_ON_CREATE_MESSAGE,
954
+ );
955
+ expect(alertLookup).not.toHaveBeenCalled();
956
+ expect(authorization).not.toHaveBeenCalled();
957
+ });
958
+
959
+ test("an Acknowledged state without an order (missing or null) counts as no state: a 400, nothing else read or checked", async () => {
960
+ for (const order of [undefined, null]) {
961
+ const orderless: AlertState = new AlertState();
962
+ orderless._id = ALERT_ACKNOWLEDGED.id;
963
+
964
+ if (order === null) {
965
+ orderless.order = null as unknown as number;
966
+ }
967
+
968
+ acknowledgedStateLookup.mockResolvedValueOnce(orderless as never);
969
+
970
+ const error: unknown = await rejection(validate());
971
+
972
+ expect(error).toBeInstanceOf(BadDataException);
973
+ expect((error as BadDataException).message).toBe(
974
+ NO_ACKNOWLEDGED_STATE_ON_CREATE_MESSAGE,
975
+ );
976
+ }
977
+
978
+ // Even for a root caller, who is never checked for permission.
979
+ const orderless: AlertState = new AlertState();
980
+ orderless._id = ALERT_ACKNOWLEDGED.id;
981
+ acknowledgedStateLookup.mockResolvedValueOnce(orderless as never);
982
+
983
+ await expect(validate({ props: { isRoot: true } })).rejects.toThrow(
984
+ NO_ACKNOWLEDGED_STATE_ON_CREATE_MESSAGE,
985
+ );
986
+
987
+ expect(acknowledgedStateLookup).toHaveBeenCalledTimes(3);
988
+ expect(alertLookup).not.toHaveBeenCalled();
989
+ expect(authorization).not.toHaveBeenCalled();
990
+ });
991
+
992
+ test("the Acknowledged state is read as root, for the project, with its id and order", async () => {
993
+ await validate();
994
+
995
+ expect(acknowledgedStateLookup).toHaveBeenCalledTimes(1);
996
+ expect(acknowledgedStateLookup).toHaveBeenCalledWith({
997
+ query: {
998
+ projectId: PROJECT_ID,
999
+ isAcknowledgedState: true,
1000
+ },
1001
+ select: {
1002
+ _id: true,
1003
+ order: true,
1004
+ },
1005
+ props: {
1006
+ isRoot: true,
1007
+ },
1008
+ });
1009
+ });
1010
+
1011
+ test("the alerts are read once, as root, in the project, for their current state's order - after the state, before the permission check", async () => {
1012
+ await validate();
1013
+
1014
+ expect(alertLookup).toHaveBeenCalledTimes(1);
1015
+ expect(alertLookup).toHaveBeenCalledWith({
1016
+ query: {
1017
+ _id: expect.any(FindOperator),
1018
+ projectId: PROJECT_ID,
1019
+ },
1020
+ select: {
1021
+ _id: true,
1022
+ currentAlertState: {
1023
+ order: true,
1024
+ },
1025
+ },
1026
+ limit: LIMIT_PER_PROJECT,
1027
+ skip: 0,
1028
+ props: {
1029
+ isRoot: true,
1030
+ },
1031
+ });
1032
+
1033
+ const query: { _id?: unknown; projectId?: unknown } = (
1034
+ alertLookup.mock.calls[0]![0] as {
1035
+ query: { _id?: unknown; projectId?: unknown };
1036
+ }
1037
+ ).query;
1038
+ expect(idsIn(query._id)).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT]);
1039
+ expect(query.projectId).toBe(PROJECT_ID);
1040
+
1041
+ expect(acknowledgedStateLookup.mock.invocationCallOrder[0]!).toBeLessThan(
1042
+ alertLookup.mock.invocationCallOrder[0]!,
1043
+ );
1044
+ expect(alertLookup.mock.invocationCallOrder[0]!).toBeLessThan(
1045
+ authorization.mock.invocationCallOrder[0]!,
1046
+ );
1047
+ });
1048
+
1049
+ test("returns the project's Acknowledged state and the alerts to acknowledge", async () => {
1050
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate();
1051
+
1052
+ expectToAcknowledge(result, [CREATED_ALERT, SECOND_CREATED_ALERT]);
1053
+ });
1054
+
1055
+ test("a root caller gets the state and the alerts not acknowledged yet, without a permission check", async () => {
1056
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1057
+ alertIds: alertIdsOf([
1058
+ CREATED_ALERT,
1059
+ RESOLVED_ALERT,
1060
+ SECOND_CREATED_ALERT,
1061
+ ]),
1062
+ props: { isRoot: true },
1063
+ });
1064
+
1065
+ expectToAcknowledge(result, [CREATED_ALERT, SECOND_CREATED_ALERT]);
1066
+ // The alerts are still read: the subset is what may be written.
1067
+ expect(alertLookup).toHaveBeenCalledTimes(1);
1068
+ expect(authorization).not.toHaveBeenCalled();
1069
+ });
1070
+
1071
+ test("a master admin gets the state and the alerts not acknowledged yet, without a permission check", async () => {
1072
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1073
+ alertIds: alertIdsOf([ACKNOWLEDGED_ALERT, CREATED_ALERT]),
1074
+ props: { ...memberProps(), isMasterAdmin: true },
1075
+ });
1076
+
1077
+ expectToAcknowledge(result, [CREATED_ALERT]);
1078
+ expect(authorization).not.toHaveBeenCalled();
1079
+ });
1080
+
1081
+ test("any other caller must be allowed to change the state of every alert that will be acknowledged", async () => {
1082
+ const props: DatabaseCommonInteractionProps = memberProps();
1083
+
1084
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1085
+ props: props,
1086
+ });
1087
+
1088
+ expect(authorization).toHaveBeenCalledTimes(1);
1089
+ expect(authorization).toHaveBeenCalledWith({
1090
+ projectId: PROJECT_ID,
1091
+ alertIds: ALERT_IDS,
1092
+ props: props,
1093
+ });
1094
+ expect((authorization.mock.calls[0]![0] as { props: unknown }).props).toBe(
1095
+ props,
1096
+ );
1097
+ expectToAcknowledge(result, [CREATED_ALERT, SECOND_CREATED_ALERT]);
1098
+ });
1099
+
1100
+ test("an API key (no user) is checked too", async () => {
1101
+ const props: DatabaseCommonInteractionProps = {
1102
+ tenantId: PROJECT_ID,
1103
+ userType: UserType.API,
1104
+ };
1105
+
1106
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1107
+ props: props,
1108
+ });
1109
+
1110
+ expect(authorization).toHaveBeenCalledTimes(1);
1111
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT]);
1112
+ expectToAcknowledge(result, [CREATED_ALERT, SECOND_CREATED_ALERT]);
1113
+ });
1114
+
1115
+ test("an unacknowledged alert with resolved and acknowledged ones: only the unacknowledged alert is checked and carried", async () => {
1116
+ const props: DatabaseCommonInteractionProps = memberProps();
1117
+
1118
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1119
+ alertIds: alertIdsOf([
1120
+ RESOLVED_ALERT,
1121
+ CREATED_ALERT,
1122
+ ACKNOWLEDGED_ALERT,
1123
+ INVESTIGATING_ALERT,
1124
+ ]),
1125
+ props: props,
1126
+ });
1127
+
1128
+ expect(authorization).toHaveBeenCalledTimes(1);
1129
+ expect(authorization).toHaveBeenCalledWith({
1130
+ projectId: PROJECT_ID,
1131
+ alertIds: [new ObjectID(CREATED_ALERT)],
1132
+ props: props,
1133
+ });
1134
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1135
+ expectToAcknowledge(result, [CREATED_ALERT]);
1136
+ });
1137
+
1138
+ test("an already resolved alert the caller may not change does not refuse the declaration", async () => {
1139
+ // The caller may change CREATED_ALERT, but not RESOLVED_ALERT.
1140
+ authorization.mockImplementation((async (args: {
1141
+ alertIds: Array<ObjectID>;
1142
+ }): Promise<void> => {
1143
+ if (ids(args.alertIds).includes(RESOLVED_ALERT)) {
1144
+ throw new NotAuthorizedException(
1145
+ "You do not have permission to change the state of one or more of these alerts.",
1146
+ );
1147
+ }
1148
+ }) as never);
1149
+
1150
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1151
+ alertIds: alertIdsOf([CREATED_ALERT, RESOLVED_ALERT]),
1152
+ });
1153
+
1154
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1155
+ expectToAcknowledge(result, [CREATED_ALERT]);
1156
+ });
1157
+
1158
+ test("an unacknowledged alert the caller may not change still refuses the declaration, whatever else is resolved", async () => {
1159
+ // The caller may change RESOLVED_ALERT, but not CREATED_ALERT.
1160
+ authorization.mockImplementation((async (args: {
1161
+ alertIds: Array<ObjectID>;
1162
+ }): Promise<void> => {
1163
+ if (ids(args.alertIds).includes(CREATED_ALERT)) {
1164
+ throw new NotAuthorizedException(
1165
+ "You do not have permission to change the state of one or more of these alerts.",
1166
+ );
1167
+ }
1168
+ }) as never);
1169
+
1170
+ const error: unknown = await rejection(
1171
+ validate({ alertIds: alertIdsOf([RESOLVED_ALERT, CREATED_ALERT]) }),
1172
+ );
1173
+
1174
+ expect(error).toBeInstanceOf(BadDataException);
1175
+ expect((error as BadDataException).message).toBe(PERMISSION_MESSAGE);
1176
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1177
+ });
1178
+
1179
+ test("every alert already acknowledged or later: no permission check, the state with nothing to acknowledge", async () => {
1180
+ // Would refuse anything it was asked about.
1181
+ authorization.mockRejectedValue(
1182
+ new NotAuthorizedException(
1183
+ "You do not have permission to change the state of one or more of these alerts.",
1184
+ ) as never,
1185
+ );
1186
+
1187
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1188
+ alertIds: alertIdsOf([
1189
+ ACKNOWLEDGED_ALERT,
1190
+ INVESTIGATING_ALERT,
1191
+ RESOLVED_ALERT,
1192
+ ]),
1193
+ props: memberProps(),
1194
+ });
1195
+
1196
+ expect(authorization).not.toHaveBeenCalled();
1197
+ expectToAcknowledge(result, []);
1198
+ expect(result!.alertIdsToAcknowledge).toEqual([]);
1199
+ });
1200
+
1201
+ test("alerts not found in the project are dropped: never checked, never carried", async () => {
1202
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1203
+ alertIds: alertIdsOf([FOREIGN_ALERT, CREATED_ALERT]),
1204
+ });
1205
+
1206
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1207
+ expectToAcknowledge(result, [CREATED_ALERT]);
1208
+ });
1209
+
1210
+ test("no alert found at all: no permission check, nothing to acknowledge", async () => {
1211
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1212
+ alertIds: alertIdsOf([FOREIGN_ALERT]),
1213
+ });
1214
+
1215
+ expect(authorization).not.toHaveBeenCalled();
1216
+ expectToAcknowledge(result, []);
1217
+ });
1218
+
1219
+ test("the alerts to acknowledge keep the order they were given in, not the order they are read in", async () => {
1220
+ // The read returns them as CREATED, SECOND, THIRD, RESOLVED.
1221
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1222
+ alertIds: alertIdsOf([
1223
+ THIRD_CREATED_ALERT,
1224
+ RESOLVED_ALERT,
1225
+ CREATED_ALERT,
1226
+ SECOND_CREATED_ALERT,
1227
+ ]),
1228
+ });
1229
+
1230
+ expect(authorizedAlertIds()).toEqual([
1231
+ THIRD_CREATED_ALERT,
1232
+ CREATED_ALERT,
1233
+ SECOND_CREATED_ALERT,
1234
+ ]);
1235
+ expectToAcknowledge(result, [
1236
+ THIRD_CREATED_ALERT,
1237
+ CREATED_ALERT,
1238
+ SECOND_CREATED_ALERT,
1239
+ ]);
1240
+ });
1241
+
1242
+ test("states are compared by order, not by flags: a custom state before Acknowledged is kept, one after is not", async () => {
1243
+ const triage: StateRow = {
1244
+ id: "0194c3a9-0000-4000-8000-0000000000d5",
1245
+ name: "Triage",
1246
+ order: 2,
1247
+ };
1248
+ // Acknowledged sits at order 5 here, after a custom "Triage".
1249
+ tables.alertStates = [
1250
+ ALERT_CREATED,
1251
+ triage,
1252
+ { ...ALERT_ACKNOWLEDGED, order: 5 },
1253
+ { ...ALERT_INVESTIGATING, order: 6 },
1254
+ { ...ALERT_RESOLVED, order: 7 },
1255
+ ];
1256
+ tables.alerts.set(CREATED_ALERT, triage.id);
1257
+
1258
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1259
+ alertIds: alertIdsOf([
1260
+ INVESTIGATING_ALERT,
1261
+ CREATED_ALERT,
1262
+ ACKNOWLEDGED_ALERT,
1263
+ ]),
1264
+ });
1265
+
1266
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1267
+ expectToAcknowledge(result, [CREATED_ALERT]);
1268
+ });
1269
+
1270
+ test("an alert whose current state has no order is not assumed acknowledged: it is checked and carried", async () => {
1271
+ // A state the project's state list does not have.
1272
+ tables.alerts.set(CREATED_ALERT, "0194c3a9-0000-4000-8000-0000000000df");
1273
+
1274
+ const result: AlertsToAcknowledgeOnDeclare | null = await validate({
1275
+ alertIds: alertIdsOf([RESOLVED_ALERT, CREATED_ALERT]),
1276
+ });
1277
+
1278
+ expect(authorizedAlertIds()).toEqual([CREATED_ALERT]);
1279
+ expectToAcknowledge(result, [CREATED_ALERT]);
1280
+ });
1281
+
1282
+ test("a caller who may not change one of the alerts gets a 400 that says what to do", async () => {
1283
+ authorization.mockRejectedValueOnce(
1284
+ new NotAuthorizedException(
1285
+ "You do not have permission to change the state of one or more of these alerts.",
1286
+ ) as never,
1287
+ );
1288
+
1289
+ const error: unknown = await rejection(validate());
1290
+
1291
+ expect(error).toBeInstanceOf(BadDataException);
1292
+ expect(error).not.toBeInstanceOf(NotAuthorizedException);
1293
+ expect((error as BadDataException).message).toBe(PERMISSION_MESSAGE);
1294
+ });
1295
+
1296
+ test("a BadDataException from the permission check is answered the same way", async () => {
1297
+ authorization.mockRejectedValueOnce(
1298
+ new BadDataException("Some permission detail.") as never,
1299
+ );
1300
+
1301
+ const error: unknown = await rejection(validate());
1302
+
1303
+ expect(error).toBeInstanceOf(BadDataException);
1304
+ expect((error as BadDataException).message).toBe(PERMISSION_MESSAGE);
1305
+ });
1306
+
1307
+ test("a lapsed session, an unpaid project or a crash keeps its own answer", async () => {
1308
+ const errors: Array<Error> = [
1309
+ new NotAuthenticatedException("Session expired."),
1310
+ new PaymentRequiredException("Please upgrade your plan."),
1311
+ new Error("connection reset"),
1312
+ ];
1313
+
1314
+ for (const thrown of errors) {
1315
+ authorization.mockRejectedValueOnce(thrown as never);
1316
+
1317
+ const error: unknown = await rejection(validate());
1318
+
1319
+ expect(error).toBe(thrown);
1320
+ }
1321
+ });
1322
+
1323
+ test("a failing Acknowledged state read is not swallowed", async () => {
1324
+ const thrown: Error = new Error("database is down");
1325
+ acknowledgedStateLookup.mockRejectedValueOnce(thrown as never);
1326
+
1327
+ await expect(validate()).rejects.toBe(thrown);
1328
+ expect(alertLookup).not.toHaveBeenCalled();
1329
+ expect(authorization).not.toHaveBeenCalled();
1330
+ });
1331
+
1332
+ test("a failing alerts read is not swallowed, and nothing is checked", async () => {
1333
+ const thrown: Error = new Error("too many clients");
1334
+ alertLookup.mockRejectedValueOnce(thrown as never);
1335
+
1336
+ await expect(validate()).rejects.toBe(thrown);
1337
+ expect(authorization).not.toHaveBeenCalled();
1338
+ });
1339
+ });
1340
+
1341
+ describe("acknowledgeAlertsDeclaredWithIncident: what is written", () => {
1342
+ test("no alerts: nothing is read or written", async () => {
1343
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([]);
1344
+
1345
+ expect(outcome(result)).toEqual(emptyOutcome());
1346
+ expect(acknowledgedStateLookup).not.toHaveBeenCalled();
1347
+ expect(incidentLookup).not.toHaveBeenCalled();
1348
+ expect(alertLookup).not.toHaveBeenCalled();
1349
+ expect(changeAlertState).not.toHaveBeenCalled();
1350
+ });
1351
+
1352
+ test("a Created alert is acknowledged as the declaring user, owners told, the incident named", async () => {
1353
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1354
+ CREATED_ALERT,
1355
+ ]);
1356
+
1357
+ expect(changeAlertState).toHaveBeenCalledTimes(1);
1358
+ expect(changeAlertState).toHaveBeenCalledWith({
1359
+ projectId: PROJECT_ID,
1360
+ alertId: new ObjectID(CREATED_ALERT),
1361
+ alertStateId: new ObjectID(ALERT_ACKNOWLEDGED.id),
1362
+ notifyOwners: true,
1363
+ rootCause:
1364
+ "Acknowledged because Incident INC-42 was declared from this alert.",
1365
+ stateChangeLog: undefined,
1366
+ createdByUserId: USER_ID,
1367
+ props: {
1368
+ isRoot: true,
1369
+ },
1370
+ });
1371
+ expect(changes[0]!.createdByUserId).toBe(USER_ID);
1372
+ expect(changes[0]!.projectId).toBe(PROJECT_ID);
1373
+
1374
+ expect(outcome(result)).toEqual({
1375
+ ...emptyOutcome(),
1376
+ acknowledged: [CREATED_ALERT],
1377
+ });
1378
+ expect(tables.alerts.get(CREATED_ALERT)).toBe(ALERT_ACKNOWLEDGED.id);
1379
+ expect(errorLog).not.toHaveBeenCalled();
1380
+ });
1381
+
1382
+ test("reads the Acknowledged state, the incident and the alerts as root", async () => {
1383
+ await acknowledge([CREATED_ALERT, SECOND_CREATED_ALERT]);
1384
+
1385
+ expect(acknowledgedStateLookup).toHaveBeenCalledTimes(1);
1386
+ expect(acknowledgedStateLookup).toHaveBeenCalledWith({
1387
+ query: {
1388
+ projectId: PROJECT_ID,
1389
+ isAcknowledgedState: true,
1390
+ },
1391
+ select: {
1392
+ _id: true,
1393
+ order: true,
1394
+ },
1395
+ props: {
1396
+ isRoot: true,
1397
+ },
1398
+ });
1399
+
1400
+ expect(incidentLookup).toHaveBeenCalledTimes(1);
1401
+ expect(incidentLookup).toHaveBeenCalledWith({
1402
+ id: INCIDENT_ID,
1403
+ select: {
1404
+ incidentNumber: true,
1405
+ incidentNumberWithPrefix: true,
1406
+ isPrivate: true,
1407
+ currentIncidentStateId: true,
1408
+ },
1409
+ props: {
1410
+ isRoot: true,
1411
+ },
1412
+ });
1413
+
1414
+ // One project-scoped read for every alert.
1415
+ expect(alertLookup).toHaveBeenCalledTimes(1);
1416
+ const query: { _id?: unknown; projectId?: unknown } = (
1417
+ alertLookup.mock.calls[0]![0] as {
1418
+ query: { _id?: unknown; projectId?: unknown };
1419
+ }
1420
+ ).query;
1421
+ expect(idsIn(query._id)).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT]);
1422
+ expect(query.projectId).toBe(PROJECT_ID);
1423
+ });
1424
+
1425
+ test("each alert is acknowledged once, however often (and in whatever case) it is listed, in first-seen order", async () => {
1426
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1427
+ CREATED_ALERT,
1428
+ CREATED_ALERT.toUpperCase(),
1429
+ SECOND_CREATED_ALERT,
1430
+ CREATED_ALERT,
1431
+ SECOND_CREATED_ALERT.toUpperCase(),
1432
+ ]);
1433
+
1434
+ expect(moved()).toEqual([
1435
+ [CREATED_ALERT, "Acknowledged"],
1436
+ [SECOND_CREATED_ALERT, "Acknowledged"],
1437
+ ]);
1438
+ expect(outcome(result)).toEqual({
1439
+ ...emptyOutcome(),
1440
+ acknowledged: [CREATED_ALERT, SECOND_CREATED_ALERT],
1441
+ });
1442
+ expect(
1443
+ idsIn(
1444
+ (alertLookup.mock.calls[0]![0] as { query: { _id?: unknown } }).query
1445
+ ._id,
1446
+ ),
1447
+ ).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT]);
1448
+ });
1449
+
1450
+ test("#number when the project has no incident prefix", async () => {
1451
+ tables.incident!.incidentNumberWithPrefix = undefined;
1452
+
1453
+ await acknowledge([CREATED_ALERT]);
1454
+
1455
+ expect(changes[0]!.rootCause).toBe(
1456
+ "Acknowledged because Incident #42 was declared from this alert.",
1457
+ );
1458
+ });
1459
+
1460
+ test("an API key declared it: the acknowledgement is credited to nobody", async () => {
1461
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge(
1462
+ [CREATED_ALERT],
1463
+ { actor: null },
1464
+ );
1465
+
1466
+ expect(changeAlertState).toHaveBeenCalledTimes(1);
1467
+ expect(changes[0]!.createdByUserId).toBeUndefined();
1468
+ expect(changes[0]!.notifyOwners).toBe(true);
1469
+ expect(changes[0]!.props).toEqual({ isRoot: true });
1470
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
1471
+ });
1472
+
1473
+ test("a private incident is never named in the alert's 'why'", async () => {
1474
+ tables.incident!.isPrivate = true;
1475
+
1476
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1477
+ CREATED_ALERT,
1478
+ SECOND_CREATED_ALERT,
1479
+ ]);
1480
+
1481
+ expect(changes).toHaveLength(2);
1482
+
1483
+ for (const change of changes) {
1484
+ expect(change.rootCause).toBe(PRIVATE_CAUSE);
1485
+ expect(change.rootCause).not.toContain("INC-42");
1486
+ expect(change.rootCause).not.toContain("42");
1487
+ expect(change.rootCause).not.toContain("Payroll");
1488
+ // Still credited to the user and still telling the owners.
1489
+ expect(change.createdByUserId).toBe(USER_ID);
1490
+ expect(change.notifyOwners).toBe(true);
1491
+ }
1492
+
1493
+ expect(outcome(result).acknowledged).toEqual([
1494
+ CREATED_ALERT,
1495
+ SECOND_CREATED_ALERT,
1496
+ ]);
1497
+ });
1498
+
1499
+ test("an incident that cannot be read back: acknowledged anyway, as 'Incident', and the sync is not consulted", async () => {
1500
+ tables.incident = null;
1501
+ setSwitches(true, true);
1502
+
1503
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1504
+ CREATED_ALERT,
1505
+ ]);
1506
+
1507
+ expect(changes[0]!.rootCause).toBe(
1508
+ "Acknowledged because Incident was declared from this alert.",
1509
+ );
1510
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
1511
+ expect(projectLookup).not.toHaveBeenCalled();
1512
+ });
1513
+
1514
+ test("alerts already acknowledged, in a later custom state or resolved are left as they are", async () => {
1515
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1516
+ ACKNOWLEDGED_ALERT,
1517
+ INVESTIGATING_ALERT,
1518
+ RESOLVED_ALERT,
1519
+ CREATED_ALERT,
1520
+ ]);
1521
+
1522
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
1523
+ expect(outcome(result)).toEqual({
1524
+ ...emptyOutcome(),
1525
+ acknowledged: [CREATED_ALERT],
1526
+ alreadyAcknowledged: [
1527
+ ACKNOWLEDGED_ALERT,
1528
+ INVESTIGATING_ALERT,
1529
+ RESOLVED_ALERT,
1530
+ ],
1531
+ });
1532
+ // Only the alert that was written is read back.
1533
+ expect(readBack).toHaveBeenCalledTimes(1);
1534
+ expect(
1535
+ String(
1536
+ (readBack.mock.calls[0]![0] as { query: { _id: ObjectID } }).query._id,
1537
+ ),
1538
+ ).toBe(CREATED_ALERT);
1539
+ expect(tables.alerts.get(RESOLVED_ALERT)).toBe(ALERT_RESOLVED.id);
1540
+ expect(tables.alerts.get(INVESTIGATING_ALERT)).toBe(ALERT_INVESTIGATING.id);
1541
+ });
1542
+
1543
+ test("states are compared by order, not by flags: a custom state before Acknowledged is acknowledged", async () => {
1544
+ const triage: StateRow = {
1545
+ id: "0194c3a9-0000-4000-8000-0000000000d5",
1546
+ name: "Triage",
1547
+ order: 1,
1548
+ };
1549
+ // Acknowledged sits at order 5 here, after a custom "Triage".
1550
+ tables.alertStates = [
1551
+ ALERT_CREATED,
1552
+ triage,
1553
+ { ...ALERT_ACKNOWLEDGED, order: 5 },
1554
+ { ...ALERT_INVESTIGATING, order: 6 },
1555
+ { ...ALERT_RESOLVED, order: 7 },
1556
+ ];
1557
+ tables.alerts.set(CREATED_ALERT, triage.id);
1558
+
1559
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1560
+ CREATED_ALERT,
1561
+ INVESTIGATING_ALERT,
1562
+ ]);
1563
+
1564
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
1565
+ expect(outcome(result)).toEqual({
1566
+ ...emptyOutcome(),
1567
+ acknowledged: [CREATED_ALERT],
1568
+ alreadyAcknowledged: [INVESTIGATING_ALERT],
1569
+ });
1570
+ });
1571
+
1572
+ test("an alert that is not in this project is reported failed and never written; the rest go on", async () => {
1573
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1574
+ FOREIGN_ALERT,
1575
+ CREATED_ALERT,
1576
+ ]);
1577
+
1578
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
1579
+ expect(outcome(result)).toEqual({
1580
+ ...emptyOutcome(),
1581
+ acknowledged: [CREATED_ALERT],
1582
+ failed: [[FOREIGN_ALERT, NOT_FOUND_MESSAGE]],
1583
+ });
1584
+ });
1585
+
1586
+ test("a project without an Acknowledged alert state: every alert failed, nothing else read or written", async () => {
1587
+ tables.alertStates = [ALERT_CREATED, ALERT_RESOLVED];
1588
+
1589
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1590
+ CREATED_ALERT,
1591
+ SECOND_CREATED_ALERT,
1592
+ CREATED_ALERT,
1593
+ ]);
1594
+
1595
+ expect(outcome(result)).toEqual({
1596
+ ...emptyOutcome(),
1597
+ failed: [
1598
+ [CREATED_ALERT, NO_ACKNOWLEDGED_STATE_MESSAGE],
1599
+ [SECOND_CREATED_ALERT, NO_ACKNOWLEDGED_STATE_MESSAGE],
1600
+ ],
1601
+ });
1602
+ expect(incidentLookup).not.toHaveBeenCalled();
1603
+ expect(alertLookup).not.toHaveBeenCalled();
1604
+ expect(changeAlertState).not.toHaveBeenCalled();
1605
+ expect(errorLog).toHaveBeenCalledWith(
1606
+ `IncidentAlertService: ${NO_ACKNOWLEDGED_STATE_MESSAGE}`,
1607
+ {
1608
+ projectId: PROJECT_ID.toString(),
1609
+ incidentId: INCIDENT_ID.toString(),
1610
+ },
1611
+ );
1612
+ });
1613
+
1614
+ test("an Acknowledged state without an order counts as no state", async () => {
1615
+ const orderless: AlertState = new AlertState();
1616
+ orderless._id = ALERT_ACKNOWLEDGED.id;
1617
+ acknowledgedStateLookup.mockResolvedValueOnce(orderless as never);
1618
+
1619
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1620
+ CREATED_ALERT,
1621
+ ]);
1622
+
1623
+ expect(outcome(result).failed).toEqual([
1624
+ [CREATED_ALERT, NO_ACKNOWLEDGED_STATE_MESSAGE],
1625
+ ]);
1626
+ expect(changeAlertState).not.toHaveBeenCalled();
1627
+ });
1628
+ });
1629
+
1630
+ describe("acknowledgeAlertsDeclaredWithIncident: classifying by reading the alert back", () => {
1631
+ test("a write refused because somebody acknowledged the alert meanwhile counts as acknowledged", async () => {
1632
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1633
+ // Somebody pressed Acknowledge first; this write is then refused.
1634
+ tables.alerts.set(CREATED_ALERT, ALERT_ACKNOWLEDGED.id);
1635
+ throw new BadDataException(
1636
+ "Alert state cannot be the same as the previous state.",
1637
+ );
1638
+ });
1639
+
1640
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1641
+ CREATED_ALERT,
1642
+ ]);
1643
+
1644
+ expect(outcome(result)).toEqual({
1645
+ ...emptyOutcome(),
1646
+ acknowledged: [CREATED_ALERT],
1647
+ });
1648
+ expect(perAlertErrorLogs()).toHaveLength(0);
1649
+ });
1650
+
1651
+ test("a write refused because somebody resolved the alert meanwhile counts as acknowledged too", async () => {
1652
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1653
+ tables.alerts.set(CREATED_ALERT, ALERT_RESOLVED.id);
1654
+ throw new BadDataException(
1655
+ "Alert state cannot transition to Acknowledged from Resolved.",
1656
+ );
1657
+ });
1658
+
1659
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1660
+ CREATED_ALERT,
1661
+ ]);
1662
+
1663
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
1664
+ expect(outcome(result).failed).toEqual([]);
1665
+ });
1666
+
1667
+ test("a write that throws and leaves the alert where it was is failed with the error's message, and logged", async () => {
1668
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1669
+ throw new Error("Could not acquire the alert's lock.");
1670
+ });
1671
+
1672
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1673
+ CREATED_ALERT,
1674
+ ]);
1675
+
1676
+ expect(outcome(result)).toEqual({
1677
+ ...emptyOutcome(),
1678
+ failed: [[CREATED_ALERT, "Could not acquire the alert's lock."]],
1679
+ });
1680
+ expect(errorLog).toHaveBeenCalledWith(
1681
+ `IncidentAlertService could not acknowledge alert ${CREATED_ALERT} for the incident it was declared with: Could not acquire the alert's lock.`,
1682
+ {
1683
+ projectId: PROJECT_ID.toString(),
1684
+ incidentId: INCIDENT_ID.toString(),
1685
+ alertId: CREATED_ALERT,
1686
+ },
1687
+ );
1688
+ });
1689
+
1690
+ test("a thrown non-Error is reported as its string", async () => {
1691
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1692
+ throw "lock timeout";
1693
+ });
1694
+
1695
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1696
+ CREATED_ALERT,
1697
+ ]);
1698
+
1699
+ expect(outcome(result).failed).toEqual([[CREATED_ALERT, "lock timeout"]]);
1700
+ });
1701
+
1702
+ test("a write that returns but leaves the alert unacknowledged is failed as 'did not change'", async () => {
1703
+ // changeAlertState returns early when the last timeline row already has the state.
1704
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1705
+ // no-op
1706
+ });
1707
+
1708
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1709
+ CREATED_ALERT,
1710
+ ]);
1711
+
1712
+ expect(changeAlertState).toHaveBeenCalledTimes(1);
1713
+ expect(outcome(result)).toEqual({
1714
+ ...emptyOutcome(),
1715
+ failed: [[CREATED_ALERT, DID_NOT_CHANGE_MESSAGE]],
1716
+ });
1717
+ expect(perAlertErrorLogs()).toHaveLength(1);
1718
+ });
1719
+
1720
+ test("an alert that cannot be read back afterwards is failed, not assumed acknowledged", async () => {
1721
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1722
+ // Deleted between the write and the read back.
1723
+ tables.alerts.delete(CREATED_ALERT);
1724
+ });
1725
+
1726
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1727
+ CREATED_ALERT,
1728
+ ]);
1729
+
1730
+ expect(readBack).toHaveBeenCalledTimes(1);
1731
+ expect(outcome(result)).toEqual({
1732
+ ...emptyOutcome(),
1733
+ failed: [[CREATED_ALERT, DID_NOT_CHANGE_MESSAGE]],
1734
+ });
1735
+ });
1736
+
1737
+ test("a read back that throws after a write that went through is failed as 'did not change', not assumed done", async () => {
1738
+ readBack.mockRejectedValueOnce(new Error("read timed out") as never);
1739
+
1740
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1741
+ CREATED_ALERT,
1742
+ ]);
1743
+
1744
+ expect(changeAlertState).toHaveBeenCalledTimes(1);
1745
+ expect(readBack).toHaveBeenCalledTimes(1);
1746
+ expect(outcome(result)).toEqual({
1747
+ ...emptyOutcome(),
1748
+ failed: [[CREATED_ALERT, DID_NOT_CHANGE_MESSAGE]],
1749
+ });
1750
+ expect(errorLog).toHaveBeenCalledWith(
1751
+ `IncidentAlertService could not acknowledge alert ${CREATED_ALERT} for the incident it was declared with: ${DID_NOT_CHANGE_MESSAGE}`,
1752
+ {
1753
+ projectId: PROJECT_ID.toString(),
1754
+ incidentId: INCIDENT_ID.toString(),
1755
+ alertId: CREATED_ALERT,
1756
+ },
1757
+ );
1758
+ });
1759
+
1760
+ test("a read back that throws after a refused write is failed with the write's message", async () => {
1761
+ writeBehaviours.set(CREATED_ALERT, async (): Promise<void> => {
1762
+ throw new Error("Could not acquire the alert's lock.");
1763
+ });
1764
+ readBack.mockRejectedValueOnce(new Error("read timed out") as never);
1765
+
1766
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1767
+ CREATED_ALERT,
1768
+ ]);
1769
+
1770
+ expect(outcome(result)).toEqual({
1771
+ ...emptyOutcome(),
1772
+ failed: [[CREATED_ALERT, "Could not acquire the alert's lock."]],
1773
+ });
1774
+ expect(perAlertErrorLogs()).toHaveLength(1);
1775
+ });
1776
+
1777
+ test("a read back that throws for one alert fails only that alert", async () => {
1778
+ readBack.mockImplementationOnce((async (): Promise<Alert | null> => {
1779
+ throw new Error("read timed out");
1780
+ }) as never);
1781
+
1782
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1783
+ CREATED_ALERT,
1784
+ SECOND_CREATED_ALERT,
1785
+ ]);
1786
+
1787
+ expect(written()).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT]);
1788
+ expect(readBack).toHaveBeenCalledTimes(2);
1789
+ expect(outcome(result)).toEqual({
1790
+ ...emptyOutcome(),
1791
+ acknowledged: [SECOND_CREATED_ALERT],
1792
+ failed: [[CREATED_ALERT, DID_NOT_CHANGE_MESSAGE]],
1793
+ });
1794
+ });
1795
+
1796
+ test("the read back is one findOneBy of the alert that was written, in its project, as root, for its state's order", async () => {
1797
+ await acknowledge([CREATED_ALERT, SECOND_CREATED_ALERT]);
1798
+
1799
+ expect(readBack).toHaveBeenCalledTimes(2);
1800
+
1801
+ const alertIds: Array<string> = [CREATED_ALERT, SECOND_CREATED_ALERT];
1802
+
1803
+ for (let index: number = 0; index < alertIds.length; index++) {
1804
+ expect(readBack).toHaveBeenNthCalledWith(index + 1, {
1805
+ query: {
1806
+ _id: new ObjectID(alertIds[index]!),
1807
+ projectId: PROJECT_ID,
1808
+ },
1809
+ select: {
1810
+ currentAlertState: {
1811
+ order: true,
1812
+ },
1813
+ },
1814
+ props: {
1815
+ isRoot: true,
1816
+ },
1817
+ });
1818
+ expect(
1819
+ (readBack.mock.calls[index]![0] as { query: { projectId: unknown } })
1820
+ .query.projectId,
1821
+ ).toBe(PROJECT_ID);
1822
+ // Each alert is read back after its own write.
1823
+ expect(changeAlertState.mock.invocationCallOrder[index]!).toBeLessThan(
1824
+ readBack.mock.invocationCallOrder[index]!,
1825
+ );
1826
+ }
1827
+
1828
+ expect(isAlertAcknowledged).not.toHaveBeenCalled();
1829
+ });
1830
+
1831
+ test("one failing alert does not stop the others, and each keeps its place", async () => {
1832
+ writeBehaviours.set(SECOND_CREATED_ALERT, async (): Promise<void> => {
1833
+ throw new Error("deadlock detected");
1834
+ });
1835
+
1836
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
1837
+ CREATED_ALERT,
1838
+ SECOND_CREATED_ALERT,
1839
+ THIRD_CREATED_ALERT,
1840
+ ACKNOWLEDGED_ALERT,
1841
+ ]);
1842
+
1843
+ expect(
1844
+ changes.map((change: StateChange) => {
1845
+ return change.alertId.toString();
1846
+ }),
1847
+ ).toEqual([CREATED_ALERT, SECOND_CREATED_ALERT, THIRD_CREATED_ALERT]);
1848
+ expect(outcome(result)).toEqual({
1849
+ ...emptyOutcome(),
1850
+ acknowledged: [CREATED_ALERT, THIRD_CREATED_ALERT],
1851
+ alreadyAcknowledged: [ACKNOWLEDGED_ALERT],
1852
+ failed: [[SECOND_CREATED_ALERT, "deadlock detected"]],
1853
+ });
1854
+ expect(perAlertErrorLogs()).toHaveLength(1);
1855
+ });
1856
+ });
1857
+
1858
+ describe("acknowledgeAlertsDeclaredWithIncident: writing in batches", () => {
1859
+ test("a few alerts are written together, not one after another", async () => {
1860
+ const alertIds: Array<string> = [
1861
+ CREATED_ALERT,
1862
+ SECOND_CREATED_ALERT,
1863
+ THIRD_CREATED_ALERT,
1864
+ ];
1865
+ const held: HeldWrites = holdWrites(alertIds);
1866
+
1867
+ const pending: Promise<AcknowledgeDeclaredAlertsResult> =
1868
+ acknowledge(alertIds);
1869
+
1870
+ await flush();
1871
+
1872
+ // All three started before any finished.
1873
+ expect(written()).toEqual(alertIds);
1874
+ expect(held.inFlight()).toBe(3);
1875
+
1876
+ // Finishing out of order changes nothing about the result's order.
1877
+ held.gates.get(THIRD_CREATED_ALERT)!.resolve();
1878
+ held.gates.get(CREATED_ALERT)!.resolve();
1879
+ held.gates.get(SECOND_CREATED_ALERT)!.resolve();
1880
+
1881
+ const result: AcknowledgeDeclaredAlertsResult = await pending;
1882
+
1883
+ expect(held.maxInFlight()).toBe(3);
1884
+ expect(outcome(result)).toEqual({
1885
+ ...emptyOutcome(),
1886
+ acknowledged: alertIds,
1887
+ });
1888
+ });
1889
+
1890
+ test("twelve alerts: at most five writes at once, batch after batch, results in the order given", async () => {
1891
+ const alertIds: Array<string> = addCreatedAlerts(12);
1892
+ const held: HeldWrites = holdWrites(alertIds);
1893
+
1894
+ // An alert left as it is takes no place in a batch.
1895
+ const pending: Promise<AcknowledgeDeclaredAlertsResult> = acknowledge([
1896
+ ...alertIds.slice(0, 3),
1897
+ ACKNOWLEDGED_ALERT,
1898
+ ...alertIds.slice(3),
1899
+ ]);
1900
+
1901
+ await flush();
1902
+
1903
+ // The first batch: the first five to write, in the order given.
1904
+ expect(written()).toEqual(alertIds.slice(0, BATCH_SIZE));
1905
+ expect(held.inFlight()).toBe(BATCH_SIZE);
1906
+
1907
+ // Four of the five finish: the next batch still waits for the fifth.
1908
+ for (const index of [4, 2, 0, 3]) {
1909
+ held.gates.get(alertIds[index]!)!.resolve();
1910
+ }
1911
+
1912
+ await flush();
1913
+
1914
+ expect(written()).toEqual(alertIds.slice(0, BATCH_SIZE));
1915
+ expect(held.inFlight()).toBe(1);
1916
+
1917
+ held.gates.get(alertIds[1]!)!.resolve();
1918
+
1919
+ await flush();
1920
+
1921
+ // The second batch starts only once the first is settled.
1922
+ expect(written()).toEqual(alertIds.slice(0, 2 * BATCH_SIZE));
1923
+ expect(held.inFlight()).toBe(BATCH_SIZE);
1924
+
1925
+ // Every write of the first batch was read back before the second began.
1926
+ const firstWriteOfSecondBatch: number =
1927
+ changeAlertState.mock.invocationCallOrder[BATCH_SIZE]!;
1928
+ const readBacksBeforeSecondBatch: Array<number> =
1929
+ readBack.mock.invocationCallOrder.filter((order: number) => {
1930
+ return order < firstWriteOfSecondBatch;
1931
+ });
1932
+ expect(readBacksBeforeSecondBatch).toHaveLength(BATCH_SIZE);
1933
+
1934
+ // The second batch finishes in reverse, and one of its writes fails.
1935
+ held.gates.get(alertIds[7]!)!.reject(new Error("deadlock detected"));
1936
+
1937
+ for (const index of [9, 8, 6, 5]) {
1938
+ held.gates.get(alertIds[index]!)!.resolve();
1939
+ }
1940
+
1941
+ await flush();
1942
+
1943
+ // The last batch: the two left.
1944
+ expect(written()).toEqual(alertIds);
1945
+ expect(held.inFlight()).toBe(2);
1946
+
1947
+ held.gates.get(alertIds[11]!)!.resolve();
1948
+ held.gates.get(alertIds[10]!)!.resolve();
1949
+
1950
+ const result: AcknowledgeDeclaredAlertsResult = await pending;
1951
+
1952
+ expect(held.maxInFlight()).toBe(BATCH_SIZE);
1953
+ expect(held.inFlight()).toBe(0);
1954
+ expect(changeAlertState).toHaveBeenCalledTimes(12);
1955
+ expect(readBack).toHaveBeenCalledTimes(12);
1956
+ expect(outcome(result)).toEqual({
1957
+ ...emptyOutcome(),
1958
+ acknowledged: [...alertIds.slice(0, 7), ...alertIds.slice(8)],
1959
+ alreadyAcknowledged: [ACKNOWLEDGED_ALERT],
1960
+ failed: [[alertIds[7]!, "deadlock detected"]],
1961
+ });
1962
+ expect(perAlertErrorLogs()).toHaveLength(1);
1963
+
1964
+ // Every alert is written once, as the declaring user, with the same why.
1965
+ for (const change of changes) {
1966
+ expect(change.alertStateId.toString()).toBe(ALERT_ACKNOWLEDGED.id);
1967
+ expect(change.createdByUserId).toBe(USER_ID);
1968
+ expect(change.rootCause).toBe(
1969
+ "Acknowledged because Incident INC-42 was declared from this alert.",
1970
+ );
1971
+ expect(change.props).toEqual({ isRoot: true });
1972
+ }
1973
+ });
1974
+
1975
+ test("exactly five alerts are one batch; a sixth waits for it", async () => {
1976
+ const alertIds: Array<string> = addCreatedAlerts(BATCH_SIZE + 1);
1977
+ const held: HeldWrites = holdWrites(alertIds);
1978
+
1979
+ const pending: Promise<AcknowledgeDeclaredAlertsResult> =
1980
+ acknowledge(alertIds);
1981
+
1982
+ await flush();
1983
+
1984
+ expect(written()).toEqual(alertIds.slice(0, BATCH_SIZE));
1985
+
1986
+ for (const alertId of alertIds.slice(0, BATCH_SIZE)) {
1987
+ held.gates.get(alertId)!.resolve();
1988
+ }
1989
+
1990
+ await flush();
1991
+
1992
+ expect(written()).toEqual(alertIds);
1993
+ expect(held.inFlight()).toBe(1);
1994
+
1995
+ held.gates.get(alertIds[BATCH_SIZE]!)!.resolve();
1996
+
1997
+ const result: AcknowledgeDeclaredAlertsResult = await pending;
1998
+
1999
+ expect(held.maxInFlight()).toBe(BATCH_SIZE);
2000
+ expect(outcome(result)).toEqual({
2001
+ ...emptyOutcome(),
2002
+ acknowledged: alertIds,
2003
+ });
2004
+ });
2005
+
2006
+ test("results keep the order given within each category, however the batch finishes", async () => {
2007
+ const alertIds: Array<string> = addCreatedAlerts(4);
2008
+ const held: HeldWrites = holdWrites(alertIds);
2009
+
2010
+ // alertIds[1] and alertIds[3] are refused and stay Created.
2011
+ const pending: Promise<AcknowledgeDeclaredAlertsResult> = acknowledge([
2012
+ FOREIGN_ALERT,
2013
+ alertIds[0]!,
2014
+ RESOLVED_ALERT,
2015
+ alertIds[1]!,
2016
+ alertIds[2]!,
2017
+ ACKNOWLEDGED_ALERT,
2018
+ alertIds[3]!,
2019
+ ]);
2020
+
2021
+ await flush();
2022
+
2023
+ // One batch of four; the others are never written.
2024
+ expect(written()).toEqual(alertIds);
2025
+
2026
+ held.gates.get(alertIds[3]!)!.reject(new Error("fourth refused"));
2027
+ held.gates.get(alertIds[2]!)!.resolve();
2028
+ held.gates.get(alertIds[1]!)!.reject(new Error("second refused"));
2029
+ held.gates.get(alertIds[0]!)!.resolve();
2030
+
2031
+ const result: AcknowledgeDeclaredAlertsResult = await pending;
2032
+
2033
+ expect(outcome(result)).toEqual({
2034
+ ...emptyOutcome(),
2035
+ acknowledged: [alertIds[0]!, alertIds[2]!],
2036
+ alreadyAcknowledged: [RESOLVED_ALERT, ACKNOWLEDGED_ALERT],
2037
+ failed: [
2038
+ [FOREIGN_ALERT, NOT_FOUND_MESSAGE],
2039
+ [alertIds[1]!, "second refused"],
2040
+ [alertIds[3]!, "fourth refused"],
2041
+ ],
2042
+ });
2043
+ });
2044
+ });
2045
+
2046
+ describe("acknowledgeAlertsDeclaredWithIncident: never throws", () => {
2047
+ test("the Acknowledged state read failing: resolves with every alert failed with that message", async () => {
2048
+ acknowledgedStateLookup.mockRejectedValueOnce(
2049
+ new Error("connection reset") as never,
2050
+ );
2051
+
2052
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2053
+ CREATED_ALERT,
2054
+ SECOND_CREATED_ALERT,
2055
+ CREATED_ALERT.toUpperCase(),
2056
+ ]);
2057
+
2058
+ expect(outcome(result)).toEqual({
2059
+ ...emptyOutcome(),
2060
+ failed: [
2061
+ [CREATED_ALERT, "connection reset"],
2062
+ [SECOND_CREATED_ALERT, "connection reset"],
2063
+ ],
2064
+ });
2065
+ expect(changeAlertState).not.toHaveBeenCalled();
2066
+ expect(errorLog).toHaveBeenCalledWith(
2067
+ "IncidentAlertService could not acknowledge the alerts an incident was declared from: Error: connection reset",
2068
+ {
2069
+ projectId: PROJECT_ID.toString(),
2070
+ incidentId: INCIDENT_ID.toString(),
2071
+ },
2072
+ );
2073
+ });
2074
+
2075
+ test("the incident read failing: every alert failed, none written", async () => {
2076
+ incidentLookup.mockRejectedValueOnce(new Error("timeout") as never);
2077
+
2078
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2079
+ CREATED_ALERT,
2080
+ ]);
2081
+
2082
+ expect(outcome(result).failed).toEqual([[CREATED_ALERT, "timeout"]]);
2083
+ expect(changeAlertState).not.toHaveBeenCalled();
2084
+ });
2085
+
2086
+ test("the alerts read failing: every alert failed, none written", async () => {
2087
+ alertLookup.mockRejectedValueOnce(new Error("too many clients") as never);
2088
+
2089
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2090
+ CREATED_ALERT,
2091
+ ACKNOWLEDGED_ALERT,
2092
+ ]);
2093
+
2094
+ expect(outcome(result)).toEqual({
2095
+ ...emptyOutcome(),
2096
+ failed: [
2097
+ [CREATED_ALERT, "too many clients"],
2098
+ [ACKNOWLEDGED_ALERT, "too many clients"],
2099
+ ],
2100
+ });
2101
+ expect(changeAlertState).not.toHaveBeenCalled();
2102
+ });
2103
+
2104
+ test("the linked-alert sync lookup failing: every alert failed, none written", async () => {
2105
+ setSwitches(true, false);
2106
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2107
+ projectLookup.mockRejectedValueOnce(
2108
+ new Error("project read failed") as never,
2109
+ );
2110
+
2111
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2112
+ CREATED_ALERT,
2113
+ ]);
2114
+
2115
+ expect(outcome(result).failed).toEqual([
2116
+ [CREATED_ALERT, "project read failed"],
2117
+ ]);
2118
+ expect(changeAlertState).not.toHaveBeenCalled();
2119
+ });
2120
+
2121
+ test("a failure part-way keeps what was settled and fails only the rest, each alert once", async () => {
2122
+ /*
2123
+ * Nothing in the batches throws on its own (each write and each read
2124
+ * back is caught), so a logger that throws stands in for "anything else
2125
+ * going wrong" part-way through: here, while the last alert of the first
2126
+ * batch is being reported, before the second batch is written.
2127
+ */
2128
+ let thrown: boolean = false;
2129
+ errorLog.mockImplementation(((message: string): void => {
2130
+ if (
2131
+ !thrown &&
2132
+ message.startsWith("IncidentAlertService could not acknowledge alert ")
2133
+ ) {
2134
+ thrown = true;
2135
+ throw new Error("log sink down");
2136
+ }
2137
+ }) as never);
2138
+
2139
+ const alertIds: Array<string> = addCreatedAlerts(BATCH_SIZE + 2);
2140
+
2141
+ writeBehaviours.set(alertIds[BATCH_SIZE - 1]!, async (): Promise<void> => {
2142
+ throw new Error("deadlock detected");
2143
+ });
2144
+
2145
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2146
+ alertIds[0]!,
2147
+ ACKNOWLEDGED_ALERT,
2148
+ ...alertIds.slice(1),
2149
+ ]);
2150
+
2151
+ expect(outcome(result)).toEqual({
2152
+ ...emptyOutcome(),
2153
+ acknowledged: alertIds.slice(0, BATCH_SIZE - 1),
2154
+ alreadyAcknowledged: [ACKNOWLEDGED_ALERT],
2155
+ failed: [
2156
+ [alertIds[BATCH_SIZE - 1]!, "deadlock detected"],
2157
+ [alertIds[BATCH_SIZE]!, "log sink down"],
2158
+ [alertIds[BATCH_SIZE + 1]!, "log sink down"],
2159
+ ],
2160
+ });
2161
+ // The first batch was written; the second never was.
2162
+ expect(written()).toEqual(alertIds.slice(0, BATCH_SIZE));
2163
+ expect(tables.alerts.get(alertIds[BATCH_SIZE]!)).toBe(ALERT_CREATED.id);
2164
+ expect(tables.alerts.get(alertIds[BATCH_SIZE + 1]!)).toBe(ALERT_CREATED.id);
2165
+ expect(errorLog).toHaveBeenCalledWith(
2166
+ "IncidentAlertService could not acknowledge the alerts an incident was declared from: Error: log sink down",
2167
+ {
2168
+ projectId: PROJECT_ID.toString(),
2169
+ incidentId: INCIDENT_ID.toString(),
2170
+ },
2171
+ );
2172
+ });
2173
+ });
2174
+
2175
+ describe("acknowledgeAlertsDeclaredWithIncident: one writer per alert with the linked-alert sync", () => {
2176
+ test("switches off (the default): nothing is left to the sync, even for an incident declared resolved", async () => {
2177
+ setSwitches(false, false);
2178
+ declaredInto(INCIDENT_RESOLVED);
2179
+
2180
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2181
+ CREATED_ALERT,
2182
+ SECOND_CREATED_ALERT,
2183
+ ]);
2184
+
2185
+ expect(outcome(result)).toEqual({
2186
+ ...emptyOutcome(),
2187
+ acknowledged: [CREATED_ALERT, SECOND_CREATED_ALERT],
2188
+ });
2189
+ expect(projectLookup).toHaveBeenCalledTimes(1);
2190
+ expect(incidentStateLookup).not.toHaveBeenCalled();
2191
+ expect(alertStatesLookup).not.toHaveBeenCalled();
2192
+ });
2193
+
2194
+ test("switches on, incident declared in Created (the usual): the sync does nothing, so the alerts are acknowledged here", async () => {
2195
+ setSwitches(true, true);
2196
+ declaredInto(INCIDENT_CREATED);
2197
+
2198
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2199
+ CREATED_ALERT,
2200
+ SECOND_CREATED_ALERT,
2201
+ ]);
2202
+
2203
+ expect(outcome(result)).toEqual({
2204
+ ...emptyOutcome(),
2205
+ acknowledged: [CREATED_ALERT, SECOND_CREATED_ALERT],
2206
+ });
2207
+ expect(incidentStateLookup).toHaveBeenCalledTimes(1);
2208
+ // The incident's state calls for nothing, so alert states are not needed.
2209
+ expect(alertStatesLookup).not.toHaveBeenCalled();
2210
+ });
2211
+
2212
+ test("acknowledge switch on, incident declared Acknowledged: linked Created alerts are left to the sync, unlinked ones acknowledged here", async () => {
2213
+ setSwitches(true, false);
2214
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2215
+
2216
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge(
2217
+ [CREATED_ALERT, SECOND_CREATED_ALERT, ACKNOWLEDGED_ALERT],
2218
+ // The second alert's link failed, so the sync never runs for it.
2219
+ { linkedAlertIds: [CREATED_ALERT, ACKNOWLEDGED_ALERT] },
2220
+ );
2221
+
2222
+ expect(moved()).toEqual([[SECOND_CREATED_ALERT, "Acknowledged"]]);
2223
+ expect(changes[0]!.createdByUserId).toBe(USER_ID);
2224
+ expect(outcome(result)).toEqual({
2225
+ ...emptyOutcome(),
2226
+ acknowledged: [SECOND_CREATED_ALERT],
2227
+ alreadyAcknowledged: [ACKNOWLEDGED_ALERT],
2228
+ leftToSync: [CREATED_ALERT],
2229
+ });
2230
+ expect(tables.alerts.get(CREATED_ALERT)).toBe(ALERT_CREATED.id);
2231
+ });
2232
+
2233
+ test("a custom incident state after Acknowledged counts as acknowledged for the sync", async () => {
2234
+ setSwitches(true, false);
2235
+ declaredInto(INCIDENT_MONITORING);
2236
+
2237
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2238
+ CREATED_ALERT,
2239
+ ]);
2240
+
2241
+ expect(changeAlertState).not.toHaveBeenCalled();
2242
+ expect(outcome(result)).toEqual({
2243
+ ...emptyOutcome(),
2244
+ leftToSync: [CREATED_ALERT],
2245
+ });
2246
+ });
2247
+
2248
+ test("linked ids are matched whatever their case", async () => {
2249
+ setSwitches(true, false);
2250
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2251
+
2252
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge(
2253
+ [CREATED_ALERT],
2254
+ { linkedAlertIds: [CREATED_ALERT.toUpperCase()] },
2255
+ );
2256
+
2257
+ expect(changeAlertState).not.toHaveBeenCalled();
2258
+ expect(outcome(result).leftToSync).toEqual([CREATED_ALERT]);
2259
+ });
2260
+
2261
+ test("no linked alerts at all: the sync is not consulted, every alert is acknowledged here", async () => {
2262
+ setSwitches(true, true);
2263
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2264
+
2265
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge(
2266
+ [CREATED_ALERT],
2267
+ { linkedAlertIds: [] },
2268
+ );
2269
+
2270
+ expect(projectLookup).not.toHaveBeenCalled();
2271
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
2272
+ });
2273
+
2274
+ test("acknowledge switch on but the incident is only Created: nothing left to the sync", async () => {
2275
+ setSwitches(true, false);
2276
+ declaredInto(INCIDENT_CREATED);
2277
+
2278
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2279
+ CREATED_ALERT,
2280
+ ]);
2281
+
2282
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
2283
+ expect(outcome(result).leftToSync).toEqual([]);
2284
+ });
2285
+
2286
+ test("resolve switch only, incident declared Acknowledged: the sync does nothing, so the alerts are acknowledged here", async () => {
2287
+ setSwitches(false, true);
2288
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2289
+
2290
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2291
+ CREATED_ALERT,
2292
+ ]);
2293
+
2294
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
2295
+ expect(outcome(result).leftToSync).toEqual([]);
2296
+ });
2297
+
2298
+ test("resolve switch, incident declared Resolved: linked alerts before Resolved are left to the sync", async () => {
2299
+ setSwitches(false, true);
2300
+ declaredInto(INCIDENT_RESOLVED);
2301
+ link(CREATED_ALERT);
2302
+ link(INVESTIGATING_ALERT);
2303
+
2304
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2305
+ CREATED_ALERT,
2306
+ INVESTIGATING_ALERT,
2307
+ ]);
2308
+
2309
+ expect(changeAlertState).not.toHaveBeenCalled();
2310
+ expect(outcome(result)).toEqual({
2311
+ ...emptyOutcome(),
2312
+ // Already past Acknowledged: nothing to do here, whatever the sync does.
2313
+ alreadyAcknowledged: [INVESTIGATING_ALERT],
2314
+ leftToSync: [CREATED_ALERT],
2315
+ });
2316
+ });
2317
+
2318
+ test("resolve switch only, alert held open by another open incident: the sync will not move it, so it is acknowledged here", async () => {
2319
+ setSwitches(false, true);
2320
+ declaredInto(INCIDENT_RESOLVED);
2321
+ tables.otherIncidents.set(
2322
+ OTHER_INCIDENT_ID.toString(),
2323
+ INCIDENT_ACKNOWLEDGED.id,
2324
+ );
2325
+ link(CREATED_ALERT);
2326
+ link(SECOND_CREATED_ALERT);
2327
+ link(CREATED_ALERT, OTHER_INCIDENT_ID);
2328
+
2329
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2330
+ CREATED_ALERT,
2331
+ SECOND_CREATED_ALERT,
2332
+ ]);
2333
+
2334
+ expect(linkLookup).toHaveBeenCalled();
2335
+ expect(otherIncidentLookup).toHaveBeenCalled();
2336
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
2337
+ expect(changes[0]!.rootCause).toBe(
2338
+ "Acknowledged because Incident INC-42 was declared from this alert.",
2339
+ );
2340
+ expect(outcome(result)).toEqual({
2341
+ ...emptyOutcome(),
2342
+ acknowledged: [CREATED_ALERT],
2343
+ leftToSync: [SECOND_CREATED_ALERT],
2344
+ });
2345
+ });
2346
+
2347
+ test("both switches on, alert held open by another open incident: the sync acknowledges it instead of resolving, so it is left to the sync", async () => {
2348
+ setSwitches(true, true);
2349
+ declaredInto(INCIDENT_RESOLVED);
2350
+ tables.otherIncidents.set(
2351
+ OTHER_INCIDENT_ID.toString(),
2352
+ INCIDENT_CREATED.id,
2353
+ );
2354
+ link(CREATED_ALERT);
2355
+ link(CREATED_ALERT, OTHER_INCIDENT_ID);
2356
+
2357
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2358
+ CREATED_ALERT,
2359
+ ]);
2360
+
2361
+ expect(changeAlertState).not.toHaveBeenCalled();
2362
+ expect(outcome(result)).toEqual({
2363
+ ...emptyOutcome(),
2364
+ leftToSync: [CREATED_ALERT],
2365
+ });
2366
+ });
2367
+
2368
+ test("resolve switch only, the other incident is itself resolved: nothing holds the alert, so it is left to the sync", async () => {
2369
+ setSwitches(false, true);
2370
+ declaredInto(INCIDENT_RESOLVED);
2371
+ tables.otherIncidents.set(
2372
+ OTHER_INCIDENT_ID.toString(),
2373
+ INCIDENT_RESOLVED.id,
2374
+ );
2375
+ link(CREATED_ALERT);
2376
+ link(CREATED_ALERT, OTHER_INCIDENT_ID);
2377
+
2378
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2379
+ CREATED_ALERT,
2380
+ ]);
2381
+
2382
+ expect(changeAlertState).not.toHaveBeenCalled();
2383
+ expect(outcome(result).leftToSync).toEqual([CREATED_ALERT]);
2384
+ });
2385
+
2386
+ test("the incident's state is not one of the project's: nothing is left to the sync", async () => {
2387
+ setSwitches(true, true);
2388
+ tables.incident!.stateId = "0194c3a9-0000-4000-8000-0000000000cf";
2389
+
2390
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2391
+ CREATED_ALERT,
2392
+ ]);
2393
+
2394
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
2395
+ expect(outcome(result).leftToSync).toEqual([]);
2396
+ });
2397
+
2398
+ test("a linked alert whose state is unknown to the project is not assumed to be the sync's", async () => {
2399
+ setSwitches(true, false);
2400
+ declaredInto(INCIDENT_ACKNOWLEDGED);
2401
+ // A state the project's state list does not have.
2402
+ tables.alerts.set(CREATED_ALERT, "0194c3a9-0000-4000-8000-0000000000df");
2403
+
2404
+ const result: AcknowledgeDeclaredAlertsResult = await acknowledge([
2405
+ CREATED_ALERT,
2406
+ ]);
2407
+
2408
+ // Written here (its order is unknown, so it is not "already acknowledged").
2409
+ expect(changeAlertState).toHaveBeenCalledTimes(1);
2410
+ expect(outcome(result).leftToSync).toEqual([]);
2411
+ expect(outcome(result).acknowledged).toEqual([CREATED_ALERT]);
2412
+ });
2413
+ });
2414
+
2415
+ describe("AlertService.changeAlertState", () => {
2416
+ const ALERT_ID: ObjectID = new ObjectID(CREATED_ALERT);
2417
+ const ACKNOWLEDGED_STATE_ID: ObjectID = new ObjectID(ALERT_ACKNOWLEDGED.id);
2418
+
2419
+ let lastTimelineLookup: jest.SpyInstance;
2420
+ let timelineCreate: jest.SpyInstance;
2421
+ let lastTimeline: AlertStateTimeline | null;
2422
+
2423
+ beforeEach(() => {
2424
+ // The service's own method, not the stub the other blocks use.
2425
+ changeAlertState.mockRestore();
2426
+
2427
+ lastTimeline = new AlertStateTimeline();
2428
+ lastTimeline._id = "0194c3a9-0000-4000-8000-0000000000f1";
2429
+ lastTimeline.alertStateId = new ObjectID(ALERT_CREATED.id);
2430
+
2431
+ lastTimelineLookup = jest
2432
+ .spyOn(AlertStateTimelineService, "findOneBy")
2433
+ .mockImplementation((async (): Promise<AlertStateTimeline | null> => {
2434
+ return lastTimeline;
2435
+ }) as never);
2436
+
2437
+ timelineCreate = jest
2438
+ .spyOn(AlertStateTimelineService, "create")
2439
+ .mockImplementation((async (args: {
2440
+ data: AlertStateTimeline;
2441
+ }): Promise<AlertStateTimeline> => {
2442
+ return args.data;
2443
+ }) as never);
2444
+ });
2445
+
2446
+ function createdRow(): AlertStateTimeline {
2447
+ expect(timelineCreate).toHaveBeenCalledTimes(1);
2448
+ return (timelineCreate.mock.calls[0]![0] as { data: AlertStateTimeline })
2449
+ .data;
2450
+ }
2451
+
2452
+ test("credits the new timeline row to the user it is given", async () => {
2453
+ await AlertService.changeAlertState({
2454
+ projectId: PROJECT_ID,
2455
+ alertId: ALERT_ID,
2456
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2457
+ notifyOwners: true,
2458
+ rootCause:
2459
+ "Acknowledged because Incident INC-42 was declared from this alert.",
2460
+ stateChangeLog: undefined,
2461
+ createdByUserId: USER_ID,
2462
+ props: { isRoot: true },
2463
+ });
2464
+
2465
+ const row: AlertStateTimeline = createdRow();
2466
+
2467
+ expect(row).toBeInstanceOf(AlertStateTimeline);
2468
+ expect(row.createdByUserId).toBe(USER_ID);
2469
+ expect(row.alertId).toBe(ALERT_ID);
2470
+ expect(row.alertStateId).toBe(ACKNOWLEDGED_STATE_ID);
2471
+ expect(row.projectId).toBe(PROJECT_ID);
2472
+ expect(row.rootCause).toBe(
2473
+ "Acknowledged because Incident INC-42 was declared from this alert.",
2474
+ );
2475
+ expect(row.isOwnerNotified).toBe(false);
2476
+ expect(
2477
+ (timelineCreate.mock.calls[0]![0] as { props: unknown }).props,
2478
+ ).toEqual({ isRoot: true });
2479
+ });
2480
+
2481
+ test("leaves the row uncredited when no user is given (a system change)", async () => {
2482
+ await AlertService.changeAlertState({
2483
+ projectId: PROJECT_ID,
2484
+ alertId: ALERT_ID,
2485
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2486
+ notifyOwners: false,
2487
+ rootCause:
2488
+ "Acknowledged because linked Incident INC-42 was acknowledged.",
2489
+ stateChangeLog: undefined,
2490
+ props: { isRoot: true },
2491
+ });
2492
+
2493
+ expect(createdRow().createdByUserId).toBeUndefined();
2494
+ });
2495
+
2496
+ test("an explicit undefined user leaves the row uncredited too", async () => {
2497
+ await AlertService.changeAlertState({
2498
+ projectId: PROJECT_ID,
2499
+ alertId: ALERT_ID,
2500
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2501
+ notifyOwners: true,
2502
+ rootCause: undefined,
2503
+ stateChangeLog: undefined,
2504
+ createdByUserId: undefined,
2505
+ props: { isRoot: true },
2506
+ });
2507
+
2508
+ const row: AlertStateTimeline = createdRow();
2509
+ expect(row.createdByUserId).toBeUndefined();
2510
+ expect(row.rootCause).toBeUndefined();
2511
+ expect(row.stateChangeLog).toBeUndefined();
2512
+ });
2513
+
2514
+ test("isOwnerNotified is the opposite of notifyOwners", async () => {
2515
+ await AlertService.changeAlertState({
2516
+ projectId: PROJECT_ID,
2517
+ alertId: ALERT_ID,
2518
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2519
+ notifyOwners: false,
2520
+ rootCause: undefined,
2521
+ stateChangeLog: undefined,
2522
+ createdByUserId: USER_ID,
2523
+ props: { isRoot: true },
2524
+ });
2525
+
2526
+ expect(createdRow().isOwnerNotified).toBe(true);
2527
+
2528
+ timelineCreate.mockClear();
2529
+
2530
+ await AlertService.changeAlertState({
2531
+ projectId: PROJECT_ID,
2532
+ alertId: ALERT_ID,
2533
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2534
+ notifyOwners: true,
2535
+ rootCause: undefined,
2536
+ stateChangeLog: undefined,
2537
+ createdByUserId: USER_ID,
2538
+ props: { isRoot: true },
2539
+ });
2540
+
2541
+ expect(createdRow().isOwnerNotified).toBe(false);
2542
+ });
2543
+
2544
+ test("keeps the state change log and passes empty props when given none", async () => {
2545
+ const log: JSONObject = { reason: "declared" };
2546
+
2547
+ await AlertService.changeAlertState({
2548
+ projectId: PROJECT_ID,
2549
+ alertId: ALERT_ID,
2550
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2551
+ notifyOwners: true,
2552
+ rootCause: undefined,
2553
+ stateChangeLog: log,
2554
+ createdByUserId: USER_ID,
2555
+ props: undefined,
2556
+ });
2557
+
2558
+ expect(createdRow().stateChangeLog).toBe(log);
2559
+ expect(
2560
+ (timelineCreate.mock.calls[0]![0] as { props: unknown }).props,
2561
+ ).toEqual({});
2562
+ });
2563
+
2564
+ test("reads the alert's latest timeline row, as root", async () => {
2565
+ await AlertService.changeAlertState({
2566
+ projectId: PROJECT_ID,
2567
+ alertId: ALERT_ID,
2568
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2569
+ notifyOwners: true,
2570
+ rootCause: undefined,
2571
+ stateChangeLog: undefined,
2572
+ createdByUserId: USER_ID,
2573
+ props: { isRoot: true },
2574
+ });
2575
+
2576
+ expect(lastTimelineLookup).toHaveBeenCalledTimes(1);
2577
+ expect(lastTimelineLookup).toHaveBeenCalledWith({
2578
+ query: {
2579
+ alertId: ALERT_ID,
2580
+ projectId: PROJECT_ID,
2581
+ },
2582
+ select: {
2583
+ _id: true,
2584
+ alertStateId: true,
2585
+ },
2586
+ sort: {
2587
+ createdAt: SortOrder.Descending,
2588
+ },
2589
+ props: {
2590
+ isRoot: true,
2591
+ },
2592
+ });
2593
+ });
2594
+
2595
+ test("still returns early, writing nothing, when the latest row already has the state", async () => {
2596
+ // A different ObjectID instance for the same id.
2597
+ lastTimeline!.alertStateId = new ObjectID(ALERT_ACKNOWLEDGED.id);
2598
+
2599
+ await AlertService.changeAlertState({
2600
+ projectId: PROJECT_ID,
2601
+ alertId: ALERT_ID,
2602
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2603
+ notifyOwners: true,
2604
+ rootCause:
2605
+ "Acknowledged because Incident INC-42 was declared from this alert.",
2606
+ stateChangeLog: undefined,
2607
+ createdByUserId: USER_ID,
2608
+ props: { isRoot: true },
2609
+ });
2610
+
2611
+ expect(timelineCreate).not.toHaveBeenCalled();
2612
+ });
2613
+
2614
+ test("an alert with no timeline yet gets its first row", async () => {
2615
+ lastTimeline = null;
2616
+
2617
+ await AlertService.changeAlertState({
2618
+ projectId: PROJECT_ID,
2619
+ alertId: ALERT_ID,
2620
+ alertStateId: ACKNOWLEDGED_STATE_ID,
2621
+ notifyOwners: true,
2622
+ rootCause: undefined,
2623
+ stateChangeLog: undefined,
2624
+ createdByUserId: USER_ID,
2625
+ props: { isRoot: true },
2626
+ });
2627
+
2628
+ expect(createdRow().createdByUserId).toBe(USER_ID);
2629
+ });
2630
+ });