@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,858 @@
1
+ import DatabaseConfig from "../../../../Server/DatabaseConfig";
2
+ import AlertService from "../../../../Server/Services/AlertService";
3
+ import CephClusterService from "../../../../Server/Services/CephClusterService";
4
+ import DatabaseServerService from "../../../../Server/Services/DatabaseServerService";
5
+ import DockerHostService from "../../../../Server/Services/DockerHostService";
6
+ import DockerSwarmClusterService from "../../../../Server/Services/DockerSwarmClusterService";
7
+ import HostService from "../../../../Server/Services/HostService";
8
+ import IncidentService from "../../../../Server/Services/IncidentService";
9
+ import KubernetesClusterService from "../../../../Server/Services/KubernetesClusterService";
10
+ import MonitorService from "../../../../Server/Services/MonitorService";
11
+ import PodmanHostService from "../../../../Server/Services/PodmanHostService";
12
+ import ProxmoxClusterService from "../../../../Server/Services/ProxmoxClusterService";
13
+ import ScheduledMaintenanceService from "../../../../Server/Services/ScheduledMaintenanceService";
14
+ import ServiceService from "../../../../Server/Services/ServiceService";
15
+ import VMwareVCenterService from "../../../../Server/Services/VMwareVCenterService";
16
+ import LinkedAffectedResources, {
17
+ LINKED_AFFECTED_RESOURCE_RELATIONS,
18
+ LinkedAffectedResource,
19
+ LinkedAffectedResourceRelation,
20
+ LinkedAffectedResourceType,
21
+ } from "../../../../Server/Utils/AffectedResources/LinkedAffectedResources";
22
+ import Alert from "../../../../Models/DatabaseModels/Alert";
23
+ import Incident from "../../../../Models/DatabaseModels/Incident";
24
+ import ScheduledMaintenance from "../../../../Models/DatabaseModels/ScheduledMaintenance";
25
+ import URL from "../../../../Types/API/URL";
26
+ import { JSONObject } from "../../../../Types/JSON";
27
+ import ObjectID from "../../../../Types/ObjectID";
28
+ import { getSloDashboardUrl } from "../../../../Utils/Slo/SloAffectedResourceMarkdown";
29
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
30
+ import fs from "fs";
31
+ import path from "path";
32
+
33
+ /*
34
+ * LinkedAffectedResources is what every server-side "Resources Affected"
35
+ * text is built from - feed items, owner and on-call emails, workspace
36
+ * summaries. It used to be monitor names only, while the dashboard's
37
+ * Affected Resources cards listed every relation, so an SLO burn-rate
38
+ * alert's on-call email said "No resources identified" beside a card that
39
+ * named the SLO.
40
+ */
41
+
42
+ const DASHBOARD: string = "https://oneuptime.example/dashboard";
43
+ const PROJECT_ID: ObjectID = new ObjectID(
44
+ "0193c0de-dddd-4aaa-8bbb-000000000001",
45
+ );
46
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
47
+ "0193c0de-dddd-4aaa-8bbb-000000000009",
48
+ );
49
+ const RECORD_ID: ObjectID = new ObjectID(
50
+ "0193c0de-dddd-4aaa-8bbb-000000000002",
51
+ );
52
+ const OTHER_RECORD_ID: ObjectID = new ObjectID(
53
+ "0193c0de-dddd-4aaa-8bbb-000000000003",
54
+ );
55
+
56
+ const MONITOR_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000a1";
57
+ const HOST_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000b1";
58
+ const OTHER_HOST_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000b2";
59
+ const CLUSTER_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000d1";
60
+ const SERVICE_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000e1";
61
+ const SLO_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000c1";
62
+ const SITE_ID: string = "0193c0de-dddd-4aaa-8bbb-0000000000f1";
63
+
64
+ function row(
65
+ id: string | undefined,
66
+ name: string,
67
+ projectId: ObjectID = PROJECT_ID,
68
+ ): JSONObject {
69
+ const value: JSONObject = {
70
+ name: name,
71
+ projectId: projectId as unknown as JSONObject,
72
+ };
73
+
74
+ if (id !== undefined) {
75
+ value["_id"] = id;
76
+ }
77
+
78
+ return value;
79
+ }
80
+
81
+ function resource(
82
+ type: LinkedAffectedResourceType,
83
+ id: string,
84
+ name: string,
85
+ ): LinkedAffectedResource {
86
+ return { type, id, name };
87
+ }
88
+
89
+ beforeEach(() => {
90
+ /*
91
+ * For the services' own link builders. A fresh URL per call: URL.addRoute
92
+ * mutates, so a shared one would hide a missing copy.
93
+ */
94
+ jest
95
+ .spyOn(DatabaseConfig, "getDashboardUrl")
96
+ .mockImplementation(async (): Promise<URL> => {
97
+ return URL.fromString(DASHBOARD);
98
+ });
99
+ });
100
+
101
+ afterEach(() => {
102
+ jest.restoreAllMocks();
103
+ });
104
+
105
+ describe("the relation table", () => {
106
+ test("covers the categories the dashboard's Affected Resources cards render, in their order", () => {
107
+ const displaySource: string = fs.readFileSync(
108
+ path.resolve(
109
+ __dirname,
110
+ "../../../../../App/FeatureSet/Dashboard/src/Components/AffectedResources/AffectedResourcesDisplay.tsx",
111
+ ),
112
+ { encoding: "utf8" },
113
+ );
114
+
115
+ const propsBlock: string = displaySource.slice(
116
+ displaySource.indexOf("export interface ComponentProps {"),
117
+ displaySource.indexOf("hideMonitors?:"),
118
+ );
119
+
120
+ const displayedColumns: Array<string> = Array.from(
121
+ propsBlock.matchAll(/^\s+(\w+)\?: Array<\w+> \| undefined;$/gm),
122
+ ).map((match: RegExpMatchArray): string => {
123
+ return match[1]!;
124
+ });
125
+
126
+ // Sanity: the parse found the lists, not nothing.
127
+ expect(displayedColumns).toContain("serviceLevelObjectives");
128
+
129
+ // An alert's singular `monitor` is the same category as `monitors`.
130
+ expect(
131
+ LINKED_AFFECTED_RESOURCE_RELATIONS.map(
132
+ (relation: LinkedAffectedResourceRelation): string => {
133
+ return relation.column;
134
+ },
135
+ ).filter((column: string): boolean => {
136
+ return column !== "monitor";
137
+ }),
138
+ ).toEqual(displayedColumns);
139
+ });
140
+
141
+ test("an incident is read for everything but network sites, with SLOs last", () => {
142
+ expect(
143
+ LinkedAffectedResources.getRelations(new Incident()).map(
144
+ (relation: LinkedAffectedResourceRelation): string => {
145
+ return relation.column;
146
+ },
147
+ ),
148
+ ).toEqual([
149
+ "monitors",
150
+ "hosts",
151
+ "kubernetesClusters",
152
+ "dockerHosts",
153
+ "podmanHosts",
154
+ "proxmoxClusters",
155
+ "vmwareVCenters",
156
+ "cephClusters",
157
+ "dockerSwarmClusters",
158
+ "iotFleets",
159
+ "databaseServers",
160
+ "services",
161
+ "serviceLevelObjectives",
162
+ ]);
163
+ });
164
+
165
+ test("an alert is read for its single monitor and its SLOs", () => {
166
+ const columns: Array<string> = LinkedAffectedResources.getRelations(
167
+ new Alert(),
168
+ ).map((relation: LinkedAffectedResourceRelation): string => {
169
+ return relation.column;
170
+ });
171
+
172
+ expect(columns[0]).toBe("monitor");
173
+ expect(columns).not.toContain("monitors");
174
+ expect(columns).not.toContain("networkSites");
175
+ expect(columns).toContain("hosts");
176
+ expect(columns[columns.length - 1]).toBe("serviceLevelObjectives");
177
+ });
178
+
179
+ test("a scheduled maintenance event is read for its network sites and has no SLOs", () => {
180
+ const columns: Array<string> = LinkedAffectedResources.getRelations(
181
+ new ScheduledMaintenance(),
182
+ ).map((relation: LinkedAffectedResourceRelation): string => {
183
+ return relation.column;
184
+ });
185
+
186
+ expect(columns[0]).toBe("monitors");
187
+ expect(columns).toContain("networkSites");
188
+ expect(columns).not.toContain("serviceLevelObjectives");
189
+ });
190
+ });
191
+
192
+ describe("dashboard links match each resource's own link", () => {
193
+ const PROJECT: ObjectID = PROJECT_ID;
194
+ const ID: ObjectID = new ObjectID(HOST_ID);
195
+
196
+ type LinkBuilder = () => Promise<URL>;
197
+
198
+ const CANONICAL: Array<[LinkedAffectedResourceType, LinkBuilder]> = [
199
+ [
200
+ LinkedAffectedResourceType.Monitor,
201
+ (): Promise<URL> => {
202
+ return MonitorService.getMonitorLinkInDashboard(PROJECT, ID);
203
+ },
204
+ ],
205
+ [
206
+ LinkedAffectedResourceType.Host,
207
+ (): Promise<URL> => {
208
+ return HostService.getHostLinkInDashboard(PROJECT, ID);
209
+ },
210
+ ],
211
+ [
212
+ LinkedAffectedResourceType.KubernetesCluster,
213
+ (): Promise<URL> => {
214
+ return KubernetesClusterService.getKubernetesClusterLinkInDashboard(
215
+ PROJECT,
216
+ ID,
217
+ );
218
+ },
219
+ ],
220
+ [
221
+ LinkedAffectedResourceType.DockerHost,
222
+ (): Promise<URL> => {
223
+ return DockerHostService.getDockerHostLinkInDashboard(PROJECT, ID);
224
+ },
225
+ ],
226
+ [
227
+ LinkedAffectedResourceType.PodmanHost,
228
+ (): Promise<URL> => {
229
+ return PodmanHostService.getPodmanHostLinkInDashboard(PROJECT, ID);
230
+ },
231
+ ],
232
+ [
233
+ LinkedAffectedResourceType.ProxmoxCluster,
234
+ (): Promise<URL> => {
235
+ return ProxmoxClusterService.getProxmoxClusterLinkInDashboard(
236
+ PROJECT,
237
+ ID,
238
+ );
239
+ },
240
+ ],
241
+ [
242
+ LinkedAffectedResourceType.VMwareVCenter,
243
+ (): Promise<URL> => {
244
+ return VMwareVCenterService.getVMwareVCenterLinkInDashboard(
245
+ PROJECT,
246
+ ID,
247
+ );
248
+ },
249
+ ],
250
+ [
251
+ LinkedAffectedResourceType.CephCluster,
252
+ (): Promise<URL> => {
253
+ return CephClusterService.getCephClusterLinkInDashboard(PROJECT, ID);
254
+ },
255
+ ],
256
+ [
257
+ LinkedAffectedResourceType.DockerSwarmCluster,
258
+ (): Promise<URL> => {
259
+ return DockerSwarmClusterService.getDockerSwarmClusterLinkInDashboard(
260
+ PROJECT,
261
+ ID,
262
+ );
263
+ },
264
+ ],
265
+ [
266
+ LinkedAffectedResourceType.DatabaseServer,
267
+ (): Promise<URL> => {
268
+ return DatabaseServerService.getDatabaseServerLinkInDashboard(
269
+ PROJECT,
270
+ ID,
271
+ );
272
+ },
273
+ ],
274
+ [
275
+ LinkedAffectedResourceType.Service,
276
+ (): Promise<URL> => {
277
+ return ServiceService.getServiceLinkInDashboard(PROJECT, ID);
278
+ },
279
+ ],
280
+ [
281
+ LinkedAffectedResourceType.ServiceLevelObjective,
282
+ async (): Promise<URL> => {
283
+ return getSloDashboardUrl({
284
+ dashboardUrl: URL.fromString(DASHBOARD),
285
+ projectId: PROJECT,
286
+ sloId: ID,
287
+ });
288
+ },
289
+ ],
290
+ ];
291
+
292
+ test.each(CANONICAL)(
293
+ "%s",
294
+ async (type: LinkedAffectedResourceType, canonical: LinkBuilder) => {
295
+ expect(
296
+ LinkedAffectedResources.getDashboardUrl({
297
+ dashboardUrl: URL.fromString(DASHBOARD),
298
+ projectId: PROJECT,
299
+ resource: resource(type, ID.toString(), "x"),
300
+ }).toString(),
301
+ ).toBe((await canonical()).toString());
302
+ },
303
+ );
304
+
305
+ /*
306
+ * No server-side link helper exists for these two; the routes are the
307
+ * dashboard's IOT_FLEET_VIEW and NETWORK_SITE_VIEW.
308
+ */
309
+ test.each([
310
+ [LinkedAffectedResourceType.IoTFleet, "iot"],
311
+ [LinkedAffectedResourceType.NetworkSite, "network-sites/view"],
312
+ ])("%s", (type: LinkedAffectedResourceType, route: string) => {
313
+ expect(
314
+ LinkedAffectedResources.getDashboardUrl({
315
+ dashboardUrl: URL.fromString(DASHBOARD),
316
+ projectId: PROJECT,
317
+ resource: resource(type, ID.toString(), "x"),
318
+ }).toString(),
319
+ ).toBe(`${DASHBOARD}/${PROJECT.toString()}/${route}/${ID.toString()}`);
320
+ });
321
+
322
+ test("does not mutate the caller's dashboard URL", () => {
323
+ const dashboardUrl: URL = URL.fromString(DASHBOARD);
324
+
325
+ LinkedAffectedResources.getDashboardUrl({
326
+ dashboardUrl,
327
+ projectId: PROJECT,
328
+ resource: resource(LinkedAffectedResourceType.Host, HOST_ID, "x"),
329
+ });
330
+
331
+ expect(dashboardUrl.toString()).toBe(DASHBOARD);
332
+ });
333
+ });
334
+
335
+ describe("collect", () => {
336
+ test("lists every relation in display order, whatever order the records come in", () => {
337
+ const resources: Array<LinkedAffectedResource> =
338
+ LinkedAffectedResources.collect({
339
+ projectId: PROJECT_ID,
340
+ records: [
341
+ {
342
+ projectId: PROJECT_ID,
343
+ serviceLevelObjectives: [row(SLO_ID, "Checkout")],
344
+ },
345
+ {
346
+ projectId: PROJECT_ID,
347
+ services: [row(SERVICE_ID, "checkout-api")],
348
+ },
349
+ { projectId: PROJECT_ID, hosts: [row(HOST_ID, "web-01")] },
350
+ {
351
+ projectId: PROJECT_ID,
352
+ kubernetesClusters: [row(CLUSTER_ID, "prod-eu")],
353
+ },
354
+ {
355
+ projectId: PROJECT_ID,
356
+ monitors: [row(MONITOR_ID, "checkout-web")],
357
+ },
358
+ ],
359
+ });
360
+
361
+ expect(resources).toEqual([
362
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "checkout-web"),
363
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
364
+ resource(
365
+ LinkedAffectedResourceType.KubernetesCluster,
366
+ CLUSTER_ID,
367
+ "prod-eu",
368
+ ),
369
+ resource(LinkedAffectedResourceType.Service, SERVICE_ID, "checkout-api"),
370
+ resource(
371
+ LinkedAffectedResourceType.ServiceLevelObjective,
372
+ SLO_ID,
373
+ "Checkout",
374
+ ),
375
+ ]);
376
+ });
377
+
378
+ test("reads an alert's singular monitor", () => {
379
+ expect(
380
+ LinkedAffectedResources.collect({
381
+ projectId: PROJECT_ID,
382
+ records: [{ projectId: PROJECT_ID, monitor: row(MONITOR_ID, "api") }],
383
+ }),
384
+ ).toEqual([
385
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "api"),
386
+ ]);
387
+ });
388
+
389
+ test("an alert without a monitor has none", () => {
390
+ expect(
391
+ LinkedAffectedResources.collect({
392
+ projectId: PROJECT_ID,
393
+ records: [{ projectId: PROJECT_ID, monitor: null }],
394
+ }),
395
+ ).toEqual([]);
396
+ });
397
+
398
+ test("leaves out another project's resource, linked before the write guard", () => {
399
+ const resources: Array<LinkedAffectedResource> =
400
+ LinkedAffectedResources.collect({
401
+ projectId: PROJECT_ID,
402
+ records: [
403
+ {
404
+ projectId: PROJECT_ID,
405
+ hosts: [
406
+ row(OTHER_HOST_ID, "db-of-another-project", OTHER_PROJECT_ID),
407
+ row(HOST_ID, "web-01"),
408
+ ],
409
+ },
410
+ ],
411
+ });
412
+
413
+ expect(resources).toEqual([
414
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
415
+ ]);
416
+ });
417
+
418
+ test("leaves out a resource read without its project", () => {
419
+ expect(
420
+ LinkedAffectedResources.collect({
421
+ projectId: PROJECT_ID,
422
+ records: [
423
+ { projectId: PROJECT_ID, hosts: [{ _id: HOST_ID, name: "web-01" }] },
424
+ ],
425
+ }),
426
+ ).toEqual([]);
427
+ });
428
+
429
+ test("leaves out a record of another project entirely", () => {
430
+ expect(
431
+ LinkedAffectedResources.collect({
432
+ projectId: PROJECT_ID,
433
+ records: [
434
+ {
435
+ projectId: OTHER_PROJECT_ID,
436
+ hosts: [row(HOST_ID, "web-01")],
437
+ },
438
+ ],
439
+ }),
440
+ ).toEqual([]);
441
+ });
442
+
443
+ test("the project comparison ignores case and padding", () => {
444
+ expect(
445
+ LinkedAffectedResources.collect({
446
+ projectId: PROJECT_ID,
447
+ records: [
448
+ {
449
+ projectId: ` ${PROJECT_ID.toString().toUpperCase()} `,
450
+ hosts: [
451
+ {
452
+ _id: HOST_ID,
453
+ name: "web-01",
454
+ projectId: PROJECT_ID.toString().toUpperCase(),
455
+ },
456
+ ],
457
+ },
458
+ ],
459
+ }),
460
+ ).toEqual([resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01")]);
461
+ });
462
+
463
+ test("a row without an id cannot be linked and is skipped", () => {
464
+ expect(
465
+ LinkedAffectedResources.collect({
466
+ projectId: PROJECT_ID,
467
+ records: [
468
+ {
469
+ projectId: PROJECT_ID,
470
+ hosts: [row(undefined, "unsaved"), null],
471
+ },
472
+ ],
473
+ }),
474
+ ).toEqual([]);
475
+ });
476
+
477
+ test("the same resource on several records is listed once", () => {
478
+ expect(
479
+ LinkedAffectedResources.collect({
480
+ projectId: PROJECT_ID,
481
+ records: [
482
+ { projectId: PROJECT_ID, hosts: [row(HOST_ID, "web-01")] },
483
+ { projectId: PROJECT_ID, hosts: [row(HOST_ID, "web-01")] },
484
+ null,
485
+ undefined,
486
+ ],
487
+ }),
488
+ ).toEqual([resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01")]);
489
+ });
490
+
491
+ test("a monitor and a host that share an id are still two resources", () => {
492
+ expect(
493
+ LinkedAffectedResources.collect({
494
+ projectId: PROJECT_ID,
495
+ records: [
496
+ {
497
+ projectId: PROJECT_ID,
498
+ monitors: [row(HOST_ID, "web-01 ping")],
499
+ hosts: [row(HOST_ID, "web-01")],
500
+ },
501
+ ],
502
+ }),
503
+ ).toHaveLength(2);
504
+ });
505
+ });
506
+
507
+ describe("reading a record's relations", () => {
508
+ /*
509
+ * Answers each per-relation read from `fixture`, the way the database
510
+ * would: one row per matching record, carrying only the relation that was
511
+ * selected.
512
+ */
513
+ function answerFrom(
514
+ service: { findAllBy: unknown },
515
+ fixture: Array<JSONObject>,
516
+ ): jest.SpyInstance {
517
+ return jest
518
+ .spyOn(service as typeof IncidentService, "findAllBy")
519
+ .mockImplementation((async (findAllBy: {
520
+ select: JSONObject;
521
+ }): Promise<Array<JSONObject>> => {
522
+ const column: string = Object.keys(findAllBy.select).find(
523
+ (key: string): boolean => {
524
+ return key !== "_id" && key !== "projectId";
525
+ },
526
+ )!;
527
+
528
+ return fixture.map((record: JSONObject): JSONObject => {
529
+ return {
530
+ _id: record["_id"],
531
+ projectId: record["projectId"],
532
+ [column]: record[column],
533
+ };
534
+ });
535
+ }) as never);
536
+ }
537
+
538
+ test("reads each relation separately, as root, scoped to the record's project", async () => {
539
+ const findAllBy: jest.SpyInstance = answerFrom(IncidentService, [
540
+ {
541
+ _id: RECORD_ID.toString(),
542
+ projectId: PROJECT_ID as unknown as JSONObject,
543
+ monitors: [row(MONITOR_ID, "checkout-web")],
544
+ hosts: [row(HOST_ID, "web-01")],
545
+ serviceLevelObjectives: [row(SLO_ID, "Checkout")],
546
+ },
547
+ ]);
548
+
549
+ const resources: Array<LinkedAffectedResource> =
550
+ await LinkedAffectedResources.readForIncident({
551
+ service: IncidentService,
552
+ projectId: PROJECT_ID,
553
+ incidentId: RECORD_ID,
554
+ });
555
+
556
+ expect(resources).toEqual([
557
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "checkout-web"),
558
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
559
+ resource(
560
+ LinkedAffectedResourceType.ServiceLevelObjective,
561
+ SLO_ID,
562
+ "Checkout",
563
+ ),
564
+ ]);
565
+
566
+ expect(findAllBy).toHaveBeenCalledTimes(
567
+ LinkedAffectedResources.getRelations(new Incident()).length,
568
+ );
569
+
570
+ for (const call of findAllBy.mock.calls) {
571
+ const args: {
572
+ query: JSONObject;
573
+ select: JSONObject;
574
+ props: JSONObject;
575
+ } = call[0];
576
+
577
+ expect(args.props).toEqual({ isRoot: true });
578
+ expect(args.query["projectId"]).toBe(PROJECT_ID);
579
+ expect(JSON.stringify(args.query["_id"])).toContain(RECORD_ID.toString());
580
+
581
+ // One relation per read, with the related row's project to check it against.
582
+ const relationKeys: Array<string> = Object.keys(args.select).filter(
583
+ (key: string): boolean => {
584
+ return key !== "_id" && key !== "projectId";
585
+ },
586
+ );
587
+
588
+ expect(relationKeys).toHaveLength(1);
589
+ expect(args.select[relationKeys[0]!]).toEqual({
590
+ _id: true,
591
+ name: true,
592
+ projectId: true,
593
+ });
594
+ expect(args.select["projectId"]).toBe(true);
595
+ }
596
+ });
597
+
598
+ test("an SLO burn-rate alert with no monitor reads as its SLO", async () => {
599
+ answerFrom(AlertService, [
600
+ {
601
+ _id: RECORD_ID.toString(),
602
+ projectId: PROJECT_ID as unknown as JSONObject,
603
+ monitor: null,
604
+ serviceLevelObjectives: [row(SLO_ID, "Checkout availability")],
605
+ },
606
+ ]);
607
+
608
+ const resources: Array<LinkedAffectedResource> =
609
+ await LinkedAffectedResources.readForAlert({
610
+ service: AlertService,
611
+ projectId: PROJECT_ID,
612
+ alertId: RECORD_ID,
613
+ });
614
+
615
+ expect(
616
+ LinkedAffectedResources.getText({
617
+ resources,
618
+ fallback: "No resources identified",
619
+ }),
620
+ ).toBe("Checkout availability");
621
+ });
622
+
623
+ test("merges and de-duplicates across several records", async () => {
624
+ answerFrom(AlertService, [
625
+ {
626
+ _id: RECORD_ID.toString(),
627
+ projectId: PROJECT_ID as unknown as JSONObject,
628
+ monitor: row(MONITOR_ID, "checkout-web"),
629
+ hosts: [row(HOST_ID, "web-01")],
630
+ },
631
+ {
632
+ _id: OTHER_RECORD_ID.toString(),
633
+ projectId: PROJECT_ID as unknown as JSONObject,
634
+ monitor: row(MONITOR_ID, "checkout-web"),
635
+ hosts: [row(OTHER_HOST_ID, "web-02")],
636
+ },
637
+ ]);
638
+
639
+ const resources: Array<LinkedAffectedResource> =
640
+ await LinkedAffectedResources.readForAlerts({
641
+ service: AlertService,
642
+ projectId: PROJECT_ID,
643
+ alertIds: [RECORD_ID, OTHER_RECORD_ID, RECORD_ID],
644
+ });
645
+
646
+ expect(resources).toEqual([
647
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "checkout-web"),
648
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
649
+ resource(LinkedAffectedResourceType.Host, OTHER_HOST_ID, "web-02"),
650
+ ]);
651
+ });
652
+
653
+ test("reads a scheduled maintenance event's network sites", async () => {
654
+ answerFrom(ScheduledMaintenanceService, [
655
+ {
656
+ _id: RECORD_ID.toString(),
657
+ projectId: PROJECT_ID as unknown as JSONObject,
658
+ networkSites: [row(SITE_ID, "London DC")],
659
+ },
660
+ ]);
661
+
662
+ expect(
663
+ await LinkedAffectedResources.readForScheduledMaintenance({
664
+ service: ScheduledMaintenanceService,
665
+ projectId: PROJECT_ID,
666
+ scheduledMaintenanceId: RECORD_ID,
667
+ }),
668
+ ).toEqual([
669
+ resource(LinkedAffectedResourceType.NetworkSite, SITE_ID, "London DC"),
670
+ ]);
671
+ });
672
+
673
+ test("no records, no reads", async () => {
674
+ const findAllBy: jest.SpyInstance = answerFrom(IncidentService, []);
675
+
676
+ expect(
677
+ await LinkedAffectedResources.readForIncidents({
678
+ service: IncidentService,
679
+ projectId: PROJECT_ID,
680
+ incidentIds: [],
681
+ }),
682
+ ).toEqual([]);
683
+ expect(findAllBy).not.toHaveBeenCalled();
684
+ });
685
+ });
686
+
687
+ describe("plain-text names", () => {
688
+ const RESOURCES: Array<LinkedAffectedResource> = [
689
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "checkout-web"),
690
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
691
+ resource(
692
+ LinkedAffectedResourceType.ServiceLevelObjective,
693
+ SLO_ID,
694
+ "Checkout availability",
695
+ ),
696
+ ];
697
+
698
+ test("names every resource, comma separated", () => {
699
+ expect(
700
+ LinkedAffectedResources.getText({
701
+ resources: RESOURCES,
702
+ fallback: "None",
703
+ }),
704
+ ).toBe("checkout-web, web-01, Checkout availability");
705
+ });
706
+
707
+ test("falls back when nothing is linked", () => {
708
+ expect(
709
+ LinkedAffectedResources.getText({
710
+ resources: [],
711
+ fallback: "No resources identified",
712
+ }),
713
+ ).toBe("No resources identified");
714
+ });
715
+
716
+ test("a series summary takes the monitor's place, the rest still follow", () => {
717
+ expect(
718
+ LinkedAffectedResources.getNames({
719
+ resources: RESOURCES,
720
+ seriesSummary: "Pod: checkout-7d9f-2xk | Namespace: shop",
721
+ }),
722
+ ).toEqual([
723
+ "Pod: checkout-7d9f-2xk | Namespace: shop",
724
+ "web-01",
725
+ "Checkout availability",
726
+ ]);
727
+ });
728
+
729
+ test("an empty series summary keeps the monitor", () => {
730
+ expect(
731
+ LinkedAffectedResources.getNames({
732
+ resources: RESOURCES,
733
+ seriesSummary: " ",
734
+ }),
735
+ ).toEqual(["checkout-web", "web-01", "Checkout availability"]);
736
+ });
737
+
738
+ test("a name shared by two resources is printed once, and blanks are skipped", () => {
739
+ expect(
740
+ LinkedAffectedResources.getNames({
741
+ resources: [
742
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "web-01"),
743
+ resource(LinkedAffectedResourceType.Host, HOST_ID, " web-01 "),
744
+ resource(LinkedAffectedResourceType.Service, SERVICE_ID, ""),
745
+ ],
746
+ }),
747
+ ).toEqual(["web-01"]);
748
+ });
749
+
750
+ test("the text is not escaped here - the email templates escape it", () => {
751
+ expect(
752
+ LinkedAffectedResources.getText({
753
+ resources: [
754
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "<b>web</b>"),
755
+ ],
756
+ fallback: "None",
757
+ }),
758
+ ).toBe("<b>web</b>");
759
+ });
760
+ });
761
+
762
+ describe("feed markdown", () => {
763
+ function getMarkdownLines(
764
+ resources: Array<LinkedAffectedResource>,
765
+ ): Array<string> {
766
+ return LinkedAffectedResources.getMarkdownLines({
767
+ dashboardUrl: URL.fromString(DASHBOARD),
768
+ projectId: PROJECT_ID,
769
+ resources,
770
+ });
771
+ }
772
+
773
+ test("keeps the monitor bullet, labels the rest, and ends with the SLO", () => {
774
+ expect(
775
+ getMarkdownLines([
776
+ resource(
777
+ LinkedAffectedResourceType.ServiceLevelObjective,
778
+ SLO_ID,
779
+ "Checkout availability",
780
+ ),
781
+ resource(
782
+ LinkedAffectedResourceType.Monitor,
783
+ MONITOR_ID,
784
+ "checkout-web",
785
+ ),
786
+ resource(LinkedAffectedResourceType.Host, HOST_ID, "web-01"),
787
+ resource(
788
+ LinkedAffectedResourceType.KubernetesCluster,
789
+ CLUSTER_ID,
790
+ "prod-eu",
791
+ ),
792
+ ]),
793
+ ).toEqual([
794
+ `- [checkout-web](${DASHBOARD}/${PROJECT_ID.toString()}/monitors/${MONITOR_ID})`,
795
+ `- [Host web\\-01](${DASHBOARD}/${PROJECT_ID.toString()}/host/${HOST_ID})`,
796
+ `- [Kubernetes Cluster prod\\-eu](${DASHBOARD}/${PROJECT_ID.toString()}/kubernetes/${CLUSTER_ID})`,
797
+ `- [SLO Checkout availability](${DASHBOARD}/${PROJECT_ID.toString()}/slos/${SLO_ID})`,
798
+ ]);
799
+ });
800
+
801
+ test("the monitor bullet is the one the created feeds always printed", async () => {
802
+ const [line]: Array<string> = getMarkdownLines([
803
+ resource(LinkedAffectedResourceType.Monitor, MONITOR_ID, "checkout-web"),
804
+ ]);
805
+
806
+ expect(line).toBe(
807
+ `- [checkout-web](${(
808
+ await MonitorService.getMonitorLinkInDashboard(
809
+ PROJECT_ID,
810
+ new ObjectID(MONITOR_ID),
811
+ )
812
+ ).toString()})`,
813
+ );
814
+ });
815
+
816
+ test("a hostile host name cannot re-point its link, add an image or start a heading", () => {
817
+ const [line]: Array<string> = getMarkdownLines([
818
+ resource(
819
+ LinkedAffectedResourceType.Host,
820
+ HOST_ID,
821
+ "web](https://evil.example) ![p](https://tracker.example/p.gif)\n# owned",
822
+ ),
823
+ ]);
824
+
825
+ expect(line).not.toContain("](https://evil.example)");
826
+ expect(line).not.toContain("![p](");
827
+ expect(line).not.toContain("\n");
828
+ expect(line).toBe(
829
+ `- [Host web\\]\\(https://evil.example\\) \\!\\[p\\]\\(https://tracker.example/p.gif\\) \\# owned](${DASHBOARD}/${PROJECT_ID.toString()}/host/${HOST_ID})`,
830
+ );
831
+ });
832
+
833
+ test("a hostile SLO name is escaped as the SLO helper always has", () => {
834
+ const [line]: Array<string> = getMarkdownLines([
835
+ resource(
836
+ LinkedAffectedResourceType.ServiceLevelObjective,
837
+ SLO_ID,
838
+ "x](https://evil.example)",
839
+ ),
840
+ ]);
841
+
842
+ expect(line).not.toContain("](https://evil.example)");
843
+ });
844
+
845
+ test("a nameless resource is still linked, by its kind", () => {
846
+ expect(
847
+ getMarkdownLines([
848
+ resource(LinkedAffectedResourceType.Service, SERVICE_ID, ""),
849
+ ]),
850
+ ).toEqual([
851
+ `- [Service](${DASHBOARD}/${PROJECT_ID.toString()}/service/${SERVICE_ID})`,
852
+ ]);
853
+ });
854
+
855
+ test("nothing linked, no lines", () => {
856
+ expect(getMarkdownLines([])).toEqual([]);
857
+ });
858
+ });