@oneuptime/common 14.0.6 → 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 (127) 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/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -0,0 +1,420 @@
1
+ import DatabaseConfig from "../../../Server/DatabaseConfig";
2
+ import AlertFeedService from "../../../Server/Services/AlertFeedService";
3
+ import AlertService from "../../../Server/Services/AlertService";
4
+ import IncidentFeedService from "../../../Server/Services/IncidentFeedService";
5
+ import IncidentService from "../../../Server/Services/IncidentService";
6
+ import ScheduledMaintenanceFeedService from "../../../Server/Services/ScheduledMaintenanceFeedService";
7
+ import ScheduledMaintenanceService from "../../../Server/Services/ScheduledMaintenanceService";
8
+ import AlertWorkspaceMessages from "../../../Server/Utils/Workspace/WorkspaceMessages/Alert";
9
+ import IncidentWorkspaceMessages from "../../../Server/Utils/Workspace/WorkspaceMessages/Incident";
10
+ import ScheduledMaintenanceWorkspaceMessages from "../../../Server/Utils/Workspace/WorkspaceMessages/ScheduledMaintenance";
11
+ import Alert from "../../../Models/DatabaseModels/Alert";
12
+ import Incident from "../../../Models/DatabaseModels/Incident";
13
+ import ScheduledMaintenance from "../../../Models/DatabaseModels/ScheduledMaintenance";
14
+ import URL from "../../../Types/API/URL";
15
+ import { JSONObject } from "../../../Types/JSON";
16
+ import ObjectID from "../../../Types/ObjectID";
17
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
18
+
19
+ /*
20
+ * The "🌎 Resources Affected" section of an alert's, incident's or scheduled
21
+ * maintenance event's feed items named monitors (and SLOs) only, while the
22
+ * dashboard's Affected Resources cards beside it list hosts, clusters,
23
+ * container hosts, databases, services and so on too. An incident attached
24
+ * to a host and a Kubernetes cluster had no section at all. These pin the
25
+ * section to the card: every relation, in the card's order, held to the
26
+ * record's own project, with the monitor bullet unchanged.
27
+ */
28
+
29
+ const DASHBOARD: string = "https://oneuptime.example/dashboard";
30
+ const PROJECT_ID: ObjectID = new ObjectID(
31
+ "0193c0de-eeee-4aaa-8bbb-000000000001",
32
+ );
33
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
34
+ "0193c0de-eeee-4aaa-8bbb-000000000009",
35
+ );
36
+ const RECORD_ID: ObjectID = new ObjectID(
37
+ "0193c0de-eeee-4aaa-8bbb-000000000002",
38
+ );
39
+
40
+ const MONITOR_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000a1";
41
+ const HOST_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000b1";
42
+ const FOREIGN_HOST_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000b9";
43
+ const CLUSTER_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000d1";
44
+ const DATABASE_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000d2";
45
+ const SERVICE_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000e1";
46
+ const SLO_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000c1";
47
+ const SITE_ID: string = "0193c0de-eeee-4aaa-8bbb-0000000000f1";
48
+
49
+ const HEADER: string = "🌎 **Resources Affected**:\n";
50
+
51
+ function link(route: string, id: string): string {
52
+ return `${DASHBOARD}/${PROJECT_ID.toString()}/${route}/${id}`;
53
+ }
54
+
55
+ function related(
56
+ id: string,
57
+ name: string,
58
+ projectId: ObjectID = PROJECT_ID,
59
+ ): JSONObject {
60
+ return { _id: id, name, projectId: projectId as unknown as JSONObject };
61
+ }
62
+
63
+ /*
64
+ * Answers each per-relation read from `relations`, as the database would:
65
+ * one row for the record, carrying only the relation that was selected.
66
+ */
67
+ function answerRelationReads(
68
+ service: { findAllBy: unknown },
69
+ relations: JSONObject,
70
+ ): jest.SpyInstance {
71
+ return jest
72
+ .spyOn(service as typeof IncidentService, "findAllBy")
73
+ .mockImplementation((async (findAllBy: {
74
+ select: JSONObject;
75
+ }): Promise<Array<JSONObject>> => {
76
+ const row: JSONObject = {
77
+ _id: RECORD_ID.toString(),
78
+ projectId: PROJECT_ID as unknown as JSONObject,
79
+ };
80
+
81
+ for (const column of Object.keys(findAllBy.select)) {
82
+ if (column !== "_id" && column !== "projectId") {
83
+ row[column] = relations[column] || null;
84
+ }
85
+ }
86
+
87
+ return [row];
88
+ }) as never);
89
+ }
90
+
91
+ // The bullets under the header, or null when there is no section.
92
+ function sectionLines(markdown: string, header: string): Array<string> | null {
93
+ const start: number = markdown.indexOf(header);
94
+
95
+ if (start === -1) {
96
+ return null;
97
+ }
98
+
99
+ const lines: Array<string> = markdown
100
+ .slice(start + header.length)
101
+ .replace(/^\n+/, "")
102
+ .split("\n");
103
+ const end: number = lines.indexOf("");
104
+
105
+ return end === -1 ? lines : lines.slice(0, end);
106
+ }
107
+
108
+ function postedMarkdown(spy: jest.SpyInstance): string {
109
+ expect(spy).toHaveBeenCalledTimes(1);
110
+
111
+ return (spy.mock.calls[0]![0] as { feedInfoInMarkdown: string })
112
+ .feedInfoInMarkdown;
113
+ }
114
+
115
+ beforeEach(() => {
116
+ jest
117
+ .spyOn(DatabaseConfig, "getDashboardUrl")
118
+ .mockImplementation(async (): Promise<URL> => {
119
+ return URL.fromString(DASHBOARD);
120
+ });
121
+
122
+ jest
123
+ .spyOn(IncidentWorkspaceMessages, "getIncidentCreateMessageBlocks")
124
+ .mockResolvedValue([] as never);
125
+ jest
126
+ .spyOn(AlertWorkspaceMessages, "getAlertCreateMessageBlocks")
127
+ .mockResolvedValue([] as never);
128
+ jest
129
+ .spyOn(
130
+ ScheduledMaintenanceWorkspaceMessages,
131
+ "getScheduledMaintenanceCreateMessageBlocks",
132
+ )
133
+ .mockResolvedValue([] as never);
134
+ });
135
+
136
+ afterEach(() => {
137
+ jest.restoreAllMocks();
138
+ });
139
+
140
+ describe("incident created feed item", () => {
141
+ type CreateIncidentFeedAsyncFunction = (incident: Incident) => Promise<void>;
142
+
143
+ let feedItem: jest.SpyInstance;
144
+
145
+ beforeEach(() => {
146
+ feedItem = jest
147
+ .spyOn(IncidentFeedService, "createIncidentFeedItem")
148
+ .mockResolvedValue(undefined as never);
149
+ });
150
+
151
+ async function createFeed(relations: JSONObject): Promise<string> {
152
+ answerRelationReads(IncidentService, relations);
153
+
154
+ const incident: Incident = new Incident();
155
+ incident._id = RECORD_ID.toString();
156
+ incident.projectId = PROJECT_ID;
157
+ incident.incidentNumber = 3;
158
+ incident.title = "Checkout is down";
159
+
160
+ await (
161
+ IncidentService as unknown as {
162
+ createIncidentFeedAsync: CreateIncidentFeedAsyncFunction;
163
+ }
164
+ ).createIncidentFeedAsync(incident);
165
+
166
+ return postedMarkdown(feedItem);
167
+ }
168
+
169
+ test("names every resource the Affected Resources card lists, in its order", async () => {
170
+ const markdown: string = await createFeed({
171
+ serviceLevelObjectives: [related(SLO_ID, "Checkout availability")],
172
+ services: [related(SERVICE_ID, "checkout")],
173
+ databaseServers: [related(DATABASE_ID, "orders")],
174
+ kubernetesClusters: [related(CLUSTER_ID, "prod")],
175
+ hosts: [related(HOST_ID, "web")],
176
+ monitors: [related(MONITOR_ID, "checkout-web")],
177
+ });
178
+
179
+ expect(sectionLines(markdown, HEADER)).toEqual([
180
+ `- [checkout-web](${link("monitors", MONITOR_ID)})`,
181
+ `- [Host web](${link("host", HOST_ID)})`,
182
+ `- [Kubernetes Cluster prod](${link("kubernetes", CLUSTER_ID)})`,
183
+ `- [Database orders](${link("databases", DATABASE_ID)})`,
184
+ `- [Service checkout](${link("service", SERVICE_ID)})`,
185
+ `- [SLO Checkout availability](${link("slos", SLO_ID)})`,
186
+ ]);
187
+ });
188
+
189
+ test("an incident on a host alone - no monitors - still has a section", async () => {
190
+ const markdown: string = await createFeed({
191
+ hosts: [related(HOST_ID, "web")],
192
+ });
193
+
194
+ expect(sectionLines(markdown, HEADER)).toEqual([
195
+ `- [Host web](${link("host", HOST_ID)})`,
196
+ ]);
197
+ });
198
+
199
+ test("another project's host, linked before the write guard, is not named", async () => {
200
+ const markdown: string = await createFeed({
201
+ hosts: [
202
+ related(
203
+ FOREIGN_HOST_ID,
204
+ "payments-db-of-another-project",
205
+ OTHER_PROJECT_ID,
206
+ ),
207
+ related(HOST_ID, "web"),
208
+ ],
209
+ });
210
+
211
+ expect(sectionLines(markdown, HEADER)).toEqual([
212
+ `- [Host web](${link("host", HOST_ID)})`,
213
+ ]);
214
+ expect(markdown).not.toContain("payments-db-of-another-project");
215
+ expect(markdown).not.toContain(FOREIGN_HOST_ID);
216
+ });
217
+
218
+ test("a host name an agent reported cannot inject markdown", async () => {
219
+ const markdown: string = await createFeed({
220
+ hosts: [
221
+ related(
222
+ HOST_ID,
223
+ "web](https://evil.example) ![p](https://t.example/p.gif)",
224
+ ),
225
+ ],
226
+ });
227
+
228
+ expect(markdown).not.toContain("](https://evil.example)");
229
+ expect(markdown).not.toContain("![p](");
230
+ });
231
+
232
+ test("an incident linked to nothing has no section", async () => {
233
+ expect(sectionLines(await createFeed({}), HEADER)).toBeNull();
234
+ });
235
+ });
236
+
237
+ describe("alert created feed item", () => {
238
+ type CreateAlertFeedAsyncFunction = (alertId: ObjectID) => Promise<void>;
239
+
240
+ let feedItem: jest.SpyInstance;
241
+
242
+ beforeEach(() => {
243
+ feedItem = jest
244
+ .spyOn(AlertFeedService, "createAlertFeedItem")
245
+ .mockResolvedValue(undefined as never);
246
+ });
247
+
248
+ async function createFeed(relations: JSONObject): Promise<string> {
249
+ answerRelationReads(AlertService, relations);
250
+
251
+ const alert: Alert = new Alert();
252
+ alert._id = RECORD_ID.toString();
253
+ alert.projectId = PROJECT_ID;
254
+ alert.alertNumber = 4;
255
+ alert.title = "Checkout is slow";
256
+
257
+ jest.spyOn(AlertService, "findOneById").mockResolvedValue(alert as never);
258
+
259
+ await (
260
+ AlertService as unknown as {
261
+ createAlertFeedAsync: CreateAlertFeedAsyncFunction;
262
+ }
263
+ ).createAlertFeedAsync(RECORD_ID);
264
+
265
+ return postedMarkdown(feedItem);
266
+ }
267
+
268
+ test("names the monitor first, then everything else the card lists", async () => {
269
+ const markdown: string = await createFeed({
270
+ monitor: related(MONITOR_ID, "checkout-web"),
271
+ kubernetesClusters: [related(CLUSTER_ID, "prod")],
272
+ services: [related(SERVICE_ID, "checkout")],
273
+ });
274
+
275
+ expect(sectionLines(markdown, HEADER)).toEqual([
276
+ `- [checkout-web](${link("monitors", MONITOR_ID)})`,
277
+ `- [Kubernetes Cluster prod](${link("kubernetes", CLUSTER_ID)})`,
278
+ `- [Service checkout](${link("service", SERVICE_ID)})`,
279
+ ]);
280
+ });
281
+
282
+ test("an SLO burn-rate alert on a service names both", async () => {
283
+ const markdown: string = await createFeed({
284
+ monitor: null,
285
+ services: [related(SERVICE_ID, "checkout")],
286
+ serviceLevelObjectives: [related(SLO_ID, "Checkout availability")],
287
+ });
288
+
289
+ expect(sectionLines(markdown, HEADER)).toEqual([
290
+ `- [Service checkout](${link("service", SERVICE_ID)})`,
291
+ `- [SLO Checkout availability](${link("slos", SLO_ID)})`,
292
+ ]);
293
+ });
294
+
295
+ test("another project's monitor is not named", async () => {
296
+ const markdown: string = await createFeed({
297
+ monitor: related(MONITOR_ID, "someone-elses-monitor", OTHER_PROJECT_ID),
298
+ });
299
+
300
+ expect(sectionLines(markdown, HEADER)).toBeNull();
301
+ expect(markdown).not.toContain("someone-elses-monitor");
302
+ });
303
+ });
304
+
305
+ describe("scheduled maintenance feed items", () => {
306
+ let feedItem: jest.SpyInstance;
307
+
308
+ beforeEach(() => {
309
+ feedItem = jest
310
+ .spyOn(
311
+ ScheduledMaintenanceFeedService,
312
+ "createScheduledMaintenanceFeedItem",
313
+ )
314
+ .mockResolvedValue(undefined as never);
315
+ });
316
+
317
+ test("the created item names monitors and network sites", async () => {
318
+ type CreateFeedFunction = (
319
+ scheduledMaintenance: ScheduledMaintenance,
320
+ ) => Promise<void>;
321
+
322
+ answerRelationReads(ScheduledMaintenanceService, {
323
+ networkSites: [related(SITE_ID, "London DC")],
324
+ monitors: [related(MONITOR_ID, "checkout-web")],
325
+ });
326
+
327
+ const event: ScheduledMaintenance = new ScheduledMaintenance();
328
+ event._id = RECORD_ID.toString();
329
+ event.projectId = PROJECT_ID;
330
+ event.scheduledMaintenanceNumber = 5;
331
+ event.title = "Database upgrade";
332
+
333
+ await (
334
+ ScheduledMaintenanceService as unknown as {
335
+ createScheduledMaintenanceFeedAsync: CreateFeedFunction;
336
+ }
337
+ ).createScheduledMaintenanceFeedAsync(event);
338
+
339
+ expect(sectionLines(postedMarkdown(feedItem), HEADER)).toEqual([
340
+ `- [checkout-web](${link("monitors", MONITOR_ID)})`,
341
+ `- [Network Site London DC](${link("network-sites/view", SITE_ID)})`,
342
+ ]);
343
+ });
344
+
345
+ describe("the updated item", () => {
346
+ type OnUpdateSuccessFunction = (
347
+ onUpdate: JSONObject,
348
+ updatedItemIds: Array<ObjectID>,
349
+ ) => Promise<JSONObject>;
350
+
351
+ const UPDATED_HEADER: string = "**Resources Affected**:\n";
352
+
353
+ async function update(data: JSONObject): Promise<void> {
354
+ await (
355
+ ScheduledMaintenanceService as unknown as {
356
+ onUpdateSuccess: OnUpdateSuccessFunction;
357
+ }
358
+ ).onUpdateSuccess(
359
+ {
360
+ updateBy: {
361
+ query: { _id: RECORD_ID.toString() },
362
+ data: data,
363
+ props: { tenantId: PROJECT_ID, userId: undefined },
364
+ },
365
+ carryForward: null,
366
+ },
367
+ [RECORD_ID],
368
+ );
369
+ }
370
+
371
+ test("editing the hosts alone lists what the event now affects", async () => {
372
+ const reads: jest.SpyInstance = answerRelationReads(
373
+ ScheduledMaintenanceService,
374
+ {
375
+ monitors: [related(MONITOR_ID, "checkout-web")],
376
+ hosts: [related(HOST_ID, "web")],
377
+ },
378
+ );
379
+
380
+ await update({ hosts: [{ _id: HOST_ID }] });
381
+
382
+ expect(sectionLines(postedMarkdown(feedItem), UPDATED_HEADER)).toEqual([
383
+ `- [checkout-web](${link("monitors", MONITOR_ID)})`,
384
+ `- [Host web](${link("host", HOST_ID)})`,
385
+ ]);
386
+
387
+ // Read back as root, inside the tenant's project.
388
+ for (const call of reads.mock.calls) {
389
+ const args: { query: JSONObject; props: JSONObject } = call[0];
390
+
391
+ expect(args.query["projectId"]).toBe(PROJECT_ID);
392
+ expect(args.props).toEqual({ isRoot: true });
393
+ }
394
+ });
395
+
396
+ test("another project's monitor put in the payload is not named", async () => {
397
+ answerRelationReads(ScheduledMaintenanceService, {
398
+ monitors: [
399
+ related(MONITOR_ID, "someone-elses-monitor", OTHER_PROJECT_ID),
400
+ ],
401
+ });
402
+
403
+ await update({ monitors: [{ _id: MONITOR_ID }] });
404
+
405
+ expect(feedItem).not.toHaveBeenCalled();
406
+ });
407
+
408
+ test("clearing the lists, as before, writes no section and reads nothing", async () => {
409
+ const reads: jest.SpyInstance = answerRelationReads(
410
+ ScheduledMaintenanceService,
411
+ {},
412
+ );
413
+
414
+ await update({ monitors: [], hosts: [] });
415
+
416
+ expect(reads).not.toHaveBeenCalled();
417
+ expect(feedItem).not.toHaveBeenCalled();
418
+ });
419
+ });
420
+ });
@@ -3,7 +3,6 @@ import AlertFeedService from "../../../Server/Services/AlertFeedService";
3
3
  import AlertService from "../../../Server/Services/AlertService";
4
4
  import IncidentFeedService from "../../../Server/Services/IncidentFeedService";
5
5
  import IncidentService from "../../../Server/Services/IncidentService";
6
- import MonitorService from "../../../Server/Services/MonitorService";
7
6
  import ProductAnalytics from "../../../Server/Utils/ProductAnalytics";
8
7
  import AlertWorkspaceMessages from "../../../Server/Utils/Workspace/WorkspaceMessages/Alert";
9
8
  import IncidentWorkspaceMessages from "../../../Server/Utils/Workspace/WorkspaceMessages/Incident";
@@ -26,6 +25,11 @@ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
26
25
  *
27
26
  * The same markdown is posted to Slack and Teams, and the feed renders it
28
27
  * without safe mode, so a hostile SLO name is checked end to end here too.
28
+ *
29
+ * The feed builders read those relations themselves, one per query, through
30
+ * LinkedAffectedResources; the fixtures below answer each of those reads the
31
+ * way the database would. Every other relation the section now lists is
32
+ * covered in LinkedAffectedResourcesCreatedFeed.test.ts.
29
33
  */
30
34
 
31
35
  const DASHBOARD: string = "https://oneuptime.example/dashboard";
@@ -52,8 +56,40 @@ type OnCreateSuccessFunction = (
52
56
  createdItem: Incident,
53
57
  ) => Promise<Incident>;
54
58
 
59
+ /*
60
+ * Answers the feed builder's per-relation reads from `record`: one row
61
+ * carrying only the relation that was selected, as the database would.
62
+ */
63
+ function answerRelationReads(
64
+ service: { findAllBy: unknown },
65
+ record: Incident | Alert,
66
+ ): jest.SpyInstance {
67
+ return jest
68
+ .spyOn(service as typeof IncidentService, "findAllBy")
69
+ .mockImplementation((async (findAllBy: {
70
+ select: JSONObject;
71
+ }): Promise<Array<JSONObject>> => {
72
+ const row: JSONObject = {
73
+ _id: record._id!,
74
+ projectId: record.projectId as unknown as JSONObject,
75
+ };
76
+
77
+ for (const column of Object.keys(findAllBy.select)) {
78
+ if (column !== "_id" && column !== "projectId") {
79
+ row[column] = (record as unknown as JSONObject)[column]!;
80
+ }
81
+ }
82
+
83
+ return [row];
84
+ }) as never);
85
+ }
86
+
87
+ let incidentRelationReads: jest.SpyInstance | undefined;
88
+
55
89
  // The feed writers are private; they are reached the way the create hooks reach them.
56
90
  function createIncidentFeed(incident: Incident): Promise<void> {
91
+ incidentRelationReads = answerRelationReads(IncidentService, incident);
92
+
57
93
  return (
58
94
  IncidentService as unknown as {
59
95
  createIncidentFeedAsync: CreateIncidentFeedAsyncFunction;
@@ -97,6 +133,8 @@ function buildMonitor(id: string, name: string): Monitor {
97
133
  const monitor: Monitor = new Monitor();
98
134
  monitor._id = id;
99
135
  monitor.name = name;
136
+ // The relation reads select it; a monitor of no known project is not named.
137
+ monitor.projectId = PROJECT_ID;
100
138
  return monitor;
101
139
  }
102
140
 
@@ -163,7 +201,6 @@ function resourcesAffectedLines(markdown: string): Array<string> | null {
163
201
 
164
202
  let incidentFeedItem: jest.SpyInstance;
165
203
  let alertFeedItem: jest.SpyInstance;
166
- let dashboardUrlLookup: jest.SpyInstance;
167
204
 
168
205
  function postedMarkdown(spy: jest.SpyInstance): string {
169
206
  expect(spy).toHaveBeenCalledTimes(1);
@@ -177,22 +214,12 @@ beforeEach(() => {
177
214
  * A fresh URL per call: URL.addRoute mutates, so a shared instance would
178
215
  * hide a builder that forgot to copy it.
179
216
  */
180
- dashboardUrlLookup = jest
217
+ jest
181
218
  .spyOn(DatabaseConfig, "getDashboardUrl")
182
219
  .mockImplementation(async (): Promise<URL> => {
183
220
  return URL.fromString(DASHBOARD);
184
221
  });
185
222
 
186
- jest
187
- .spyOn(MonitorService, "getMonitorLinkInDashboard")
188
- .mockImplementation(
189
- async (projectId: ObjectID, monitorId: ObjectID): Promise<URL> => {
190
- return URL.fromString(
191
- `${DASHBOARD}/${projectId.toString()}/monitors/${monitorId.toString()}`,
192
- );
193
- },
194
- );
195
-
196
223
  jest
197
224
  .spyOn(IncidentWorkspaceMessages, "getIncidentCreateMessageBlocks")
198
225
  .mockResolvedValue([] as never);
@@ -246,7 +273,7 @@ describe("incident created feed item", () => {
246
273
  ]);
247
274
  });
248
275
 
249
- test("a monitors-only incident reads exactly as before, without a dashboard lookup for SLOs", async () => {
276
+ test("a monitors-only incident reads exactly as before", async () => {
250
277
  await createIncidentFeed(
251
278
  buildIncident({ monitors: [buildMonitor(MONITOR_ID, "checkout-web")] }),
252
279
  );
@@ -254,7 +281,6 @@ describe("incident created feed item", () => {
254
281
  expect(resourcesAffectedLines(postedMarkdown(incidentFeedItem))).toEqual([
255
282
  `- [checkout-web](${monitorLink(MONITOR_ID)})`,
256
283
  ]);
257
- expect(dashboardUrlLookup).not.toHaveBeenCalled();
258
284
  });
259
285
 
260
286
  test("an incident with neither monitors nor SLOs has no Resources Affected section", async () => {
@@ -328,7 +354,31 @@ describe("incident created feed item", () => {
328
354
  expect(resourcesAffectedLines(postedMarkdown(incidentFeedItem))).toBeNull();
329
355
  });
330
356
 
331
- test("onCreateSuccess reads the incident's SLOs for the feed item", async () => {
357
+ test("reads the incident's SLOs with their project, as root", async () => {
358
+ await createIncidentFeed(buildIncident({}));
359
+
360
+ const sloRead: JSONObject | undefined = incidentRelationReads!.mock.calls
361
+ .map((call: Array<unknown>): JSONObject => {
362
+ return call[0] as JSONObject;
363
+ })
364
+ .find((findAllBy: JSONObject): boolean => {
365
+ return Boolean(
366
+ (findAllBy["select"] as JSONObject)["serviceLevelObjectives"],
367
+ );
368
+ });
369
+
370
+ /*
371
+ * projectId too: the read runs as root, and the feed names only SLOs of
372
+ * the record's own project.
373
+ */
374
+ expect(
375
+ (sloRead!["select"] as JSONObject)["serviceLevelObjectives"],
376
+ ).toEqual({ _id: true, name: true, projectId: true });
377
+ expect(sloRead!["props"]).toEqual({ isRoot: true });
378
+ expect((sloRead!["query"] as JSONObject)["projectId"]).toBe(PROJECT_ID);
379
+ });
380
+
381
+ test("onCreateSuccess leaves the relations to the feed builder's own reads", async () => {
332
382
  jest.spyOn(ProductAnalytics, "captureForUser").mockImplementation((() => {
333
383
  // no analytics in tests
334
384
  }) as never);
@@ -362,45 +412,52 @@ describe("incident created feed item", () => {
362
412
  ).select;
363
413
 
364
414
  /*
365
- * projectId too: the read runs as root, and the feed names only SLOs of
366
- * the record's own project.
415
+ * Joining every many-to-many relation into this one read would return
416
+ * the product of their sizes; LinkedAffectedResources reads them one at
417
+ * a time instead.
367
418
  */
368
- expect(select["serviceLevelObjectives"]).toEqual({
369
- name: true,
370
- _id: true,
371
- projectId: true,
372
- });
373
- // The monitors the section already listed are still read.
374
- expect(select["monitors"]).toEqual({ name: true, _id: true });
419
+ expect(select["monitors"]).toBeUndefined();
420
+ expect(select["serviceLevelObjectives"]).toBeUndefined();
421
+ expect(select["projectId"]).toBe(true);
375
422
  });
376
423
  });
377
424
 
378
425
  describe("alert created feed item", () => {
426
+ let alertRelationReads: jest.SpyInstance;
427
+
379
428
  function mockAlertRow(alert: Alert): jest.SpyInstance {
429
+ alertRelationReads = answerRelationReads(AlertService, alert);
430
+
380
431
  return jest
381
432
  .spyOn(AlertService, "findOneById")
382
433
  .mockResolvedValue(alert as never);
383
434
  }
384
435
 
385
- test("reads the alert's SLOs alongside its monitor", async () => {
386
- const findOneById: jest.SpyInstance = mockAlertRow(buildAlert({}));
436
+ test("reads the alert's SLOs alongside its monitor, with their project", async () => {
437
+ mockAlertRow(buildAlert({}));
387
438
 
388
439
  await createAlertFeed(ALERT_ID);
389
440
 
390
- const select: JSONObject = (
391
- findOneById.mock.calls[0]![0] as { select: JSONObject }
392
- ).select;
441
+ const selects: Array<JSONObject> = alertRelationReads.mock.calls.map(
442
+ (call: Array<unknown>): JSONObject => {
443
+ return (call[0] as { select: JSONObject }).select;
444
+ },
445
+ );
393
446
 
394
447
  /*
395
- * projectId too: the read runs as root, and the feed names only SLOs of
396
- * the record's own project.
448
+ * projectId too: the reads run as root, and the feed names only
449
+ * resources of the record's own project.
397
450
  */
398
- expect(select["serviceLevelObjectives"]).toEqual({
399
- name: true,
451
+ expect(selects).toContainEqual({
452
+ _id: true,
453
+ projectId: true,
454
+ serviceLevelObjectives: { _id: true, name: true, projectId: true },
455
+ });
456
+ expect(selects).toContainEqual({
400
457
  _id: true,
401
458
  projectId: true,
459
+ monitor: { _id: true, name: true, projectId: true },
402
460
  });
403
- expect(select["monitor"]).toEqual({ name: true, _id: true });
404
461
  });
405
462
 
406
463
  test("a burn-rate alert with no monitor names its SLO under Resources Affected", async () => {
@@ -446,7 +503,6 @@ describe("alert created feed item", () => {
446
503
  expect(resourcesAffectedLines(postedMarkdown(alertFeedItem))).toEqual([
447
504
  `- [checkout-web](${monitorLink(MONITOR_ID)})`,
448
505
  ]);
449
- expect(dashboardUrlLookup).not.toHaveBeenCalled();
450
506
  });
451
507
 
452
508
  test("an alert with neither monitor nor SLOs has no Resources Affected section", async () => {