@oneuptime/common 14.0.2 → 14.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2225 @@
|
|
|
1
|
+
import AlertFeedService from "../../../Server/Services/AlertFeedService";
|
|
2
|
+
import AlertOwnerTeamService from "../../../Server/Services/AlertOwnerTeamService";
|
|
3
|
+
import AlertOwnerUserService from "../../../Server/Services/AlertOwnerUserService";
|
|
4
|
+
import AlertService from "../../../Server/Services/AlertService";
|
|
5
|
+
import DatabaseService from "../../../Server/Services/DatabaseService";
|
|
6
|
+
import IncidentAlertService, {
|
|
7
|
+
INCIDENT_ALERT_DECLARED_WITH_INCIDENT_KEY,
|
|
8
|
+
LinkAlertsToIncidentResult,
|
|
9
|
+
LinkedAlertMention,
|
|
10
|
+
getDeclaredFromAlertsMarkdown,
|
|
11
|
+
} from "../../../Server/Services/IncidentAlertService";
|
|
12
|
+
import IncidentFeedService from "../../../Server/Services/IncidentFeedService";
|
|
13
|
+
import IncidentService from "../../../Server/Services/IncidentService";
|
|
14
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
15
|
+
import DeleteBy from "../../../Server/Types/Database/DeleteBy";
|
|
16
|
+
import FindBy from "../../../Server/Types/Database/FindBy";
|
|
17
|
+
import {
|
|
18
|
+
OnCreate,
|
|
19
|
+
OnDelete,
|
|
20
|
+
OnFind,
|
|
21
|
+
OnUpdate,
|
|
22
|
+
} from "../../../Server/Types/Database/Hooks";
|
|
23
|
+
import ModelPermission from "../../../Server/Types/Database/Permissions/Index";
|
|
24
|
+
import Query from "../../../Server/Types/Database/Query";
|
|
25
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
26
|
+
import PostgresErrorTranslator from "../../../Server/Utils/Database/PostgresErrorTranslator";
|
|
27
|
+
import ProjectScopedReferenceValidator from "../../../Server/Utils/Database/ProjectScopedReferenceValidator";
|
|
28
|
+
import logger from "../../../Server/Utils/Logger";
|
|
29
|
+
import Alert from "../../../Models/DatabaseModels/Alert";
|
|
30
|
+
import { AlertFeedEventType } from "../../../Models/DatabaseModels/AlertFeed";
|
|
31
|
+
import AlertOwnerTeam from "../../../Models/DatabaseModels/AlertOwnerTeam";
|
|
32
|
+
import AlertOwnerUser from "../../../Models/DatabaseModels/AlertOwnerUser";
|
|
33
|
+
import Incident from "../../../Models/DatabaseModels/Incident";
|
|
34
|
+
import IncidentAlert from "../../../Models/DatabaseModels/IncidentAlert";
|
|
35
|
+
import { IncidentFeedEventType } from "../../../Models/DatabaseModels/IncidentFeed";
|
|
36
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
37
|
+
import URL from "../../../Types/API/URL";
|
|
38
|
+
import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
39
|
+
import { Gray500, Yellow500 } from "../../../Types/BrandColors";
|
|
40
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
41
|
+
import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
|
|
42
|
+
import PaymentRequiredException from "../../../Types/Exception/PaymentRequiredException";
|
|
43
|
+
import {
|
|
44
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
45
|
+
MAX_ALERTS_PER_INCIDENT_LINK_ACTION,
|
|
46
|
+
} from "../../../Types/Incident/IncidentAlertLink";
|
|
47
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
48
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
49
|
+
import Permission, {
|
|
50
|
+
UserTenantAccessPermission,
|
|
51
|
+
} from "../../../Types/Permission";
|
|
52
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
53
|
+
import UserType from "../../../Types/UserType";
|
|
54
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
55
|
+
import { FindOperator } from "typeorm";
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* IncidentAlertService owns the alert <-> incident link. Every refusal and
|
|
59
|
+
* side effect it is responsible for is pinned here against stubbed
|
|
60
|
+
* neighbours: what a link must reference, who may create it, what it writes
|
|
61
|
+
* to both feeds, which deleted rows it reports, how its reads are narrowed to
|
|
62
|
+
* what the caller can see, and the helpers behind "declare incident from
|
|
63
|
+
* alerts". The state cascade has its own file (IncidentAlertStateCascade).
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
67
|
+
"0194a1e7-0000-4000-8000-000000000001",
|
|
68
|
+
);
|
|
69
|
+
const OTHER_PROJECT_ID: ObjectID = new ObjectID(
|
|
70
|
+
"0194a1e7-0000-4000-8000-000000000002",
|
|
71
|
+
);
|
|
72
|
+
const INCIDENT_ID: ObjectID = new ObjectID(
|
|
73
|
+
"0194a1e7-0000-4000-8000-0000000000a1",
|
|
74
|
+
);
|
|
75
|
+
const OTHER_INCIDENT_ID: ObjectID = new ObjectID(
|
|
76
|
+
"0194a1e7-0000-4000-8000-0000000000a2",
|
|
77
|
+
);
|
|
78
|
+
const ALERT_ID: ObjectID = new ObjectID("0194a1e7-0000-4000-8000-0000000000b1");
|
|
79
|
+
const ALERT_ID_2: ObjectID = new ObjectID(
|
|
80
|
+
"0194a1e7-0000-4000-8000-0000000000b2",
|
|
81
|
+
);
|
|
82
|
+
const ALERT_ID_3: ObjectID = new ObjectID(
|
|
83
|
+
"0194a1e7-0000-4000-8000-0000000000b3",
|
|
84
|
+
);
|
|
85
|
+
const USER_ID: ObjectID = new ObjectID("0194a1e7-0000-4000-8000-0000000000c1");
|
|
86
|
+
const OTHER_USER_ID: ObjectID = new ObjectID(
|
|
87
|
+
"0194a1e7-0000-4000-8000-0000000000c2",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const INCIDENT_URL: string = "https://oneuptime.example/dashboard/incident";
|
|
91
|
+
const ALERT_URL: string = "https://oneuptime.example/dashboard/alert";
|
|
92
|
+
|
|
93
|
+
type HookFunction = (...args: Array<unknown>) => Promise<unknown>;
|
|
94
|
+
|
|
95
|
+
function callHook<T>(name: string, ...args: Array<unknown>): Promise<T> {
|
|
96
|
+
const hooks: Record<string, HookFunction> =
|
|
97
|
+
IncidentAlertService as unknown as Record<string, HookFunction>;
|
|
98
|
+
return hooks[name]!.apply(IncidentAlertService, args) as Promise<T>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pass null for an API key, which acts for no user.
|
|
102
|
+
function userProps(
|
|
103
|
+
permission: Permission,
|
|
104
|
+
userId: ObjectID | null = USER_ID,
|
|
105
|
+
): DatabaseCommonInteractionProps {
|
|
106
|
+
const tenantPermission: UserTenantAccessPermission = {
|
|
107
|
+
projectId: PROJECT_ID,
|
|
108
|
+
_type: "UserTenantAccessPermission",
|
|
109
|
+
permissions: [
|
|
110
|
+
{
|
|
111
|
+
_type: "UserPermission",
|
|
112
|
+
permission: permission,
|
|
113
|
+
labelIds: [],
|
|
114
|
+
isBlockPermission: false,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
tenantId: PROJECT_ID,
|
|
121
|
+
userId: userId || undefined,
|
|
122
|
+
userType: userId ? UserType.User : UserType.API,
|
|
123
|
+
userTenantAccessPermission: {
|
|
124
|
+
[PROJECT_ID.toString()]: tenantPermission,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildLink(data: Partial<IncidentAlert> = {}): IncidentAlert {
|
|
130
|
+
const link: IncidentAlert = new IncidentAlert();
|
|
131
|
+
link.projectId = PROJECT_ID;
|
|
132
|
+
link.incidentId = INCIDENT_ID;
|
|
133
|
+
link.alertId = ALERT_ID;
|
|
134
|
+
Object.assign(link, data);
|
|
135
|
+
return link;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildIncident(projectId: ObjectID = PROJECT_ID): Incident {
|
|
139
|
+
const incident: Incident = new Incident();
|
|
140
|
+
incident._id = INCIDENT_ID.toString();
|
|
141
|
+
incident.projectId = projectId;
|
|
142
|
+
return incident;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildAlert(projectId: ObjectID = PROJECT_ID): Alert {
|
|
146
|
+
const alert: Alert = new Alert();
|
|
147
|
+
alert._id = ALERT_ID.toString();
|
|
148
|
+
alert.projectId = projectId;
|
|
149
|
+
return alert;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function alertRow(id: ObjectID): Alert {
|
|
153
|
+
const alert: Alert = new Alert();
|
|
154
|
+
alert._id = id.toString();
|
|
155
|
+
return alert;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The SQL of every Raw clause inside a (possibly And-combined) operator.
|
|
159
|
+
function operatorSql(value: unknown): Array<string> {
|
|
160
|
+
if (!(value instanceof FindOperator)) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (value.type === "and") {
|
|
165
|
+
return (value.value as unknown as Array<unknown>).flatMap(operatorSql);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const getSql: ((alias: string) => string) | undefined = value.getSql as
|
|
169
|
+
| ((alias: string) => string)
|
|
170
|
+
| undefined;
|
|
171
|
+
|
|
172
|
+
return getSql ? [getSql("COLUMN")] : [value.type];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let validator: jest.SpyInstance;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
validator = jest
|
|
179
|
+
.spyOn(ProjectScopedReferenceValidator, "validateReferencesBelongToProject")
|
|
180
|
+
.mockResolvedValue(undefined as never);
|
|
181
|
+
jest.spyOn(logger, "error").mockImplementation((() => {
|
|
182
|
+
// quiet
|
|
183
|
+
}) as never);
|
|
184
|
+
jest.spyOn(logger, "debug").mockImplementation((() => {
|
|
185
|
+
// quiet
|
|
186
|
+
}) as never);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
jest.restoreAllMocks();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("onBeforeCreate: what a link must reference", () => {
|
|
194
|
+
function create(
|
|
195
|
+
data: Partial<IncidentAlert>,
|
|
196
|
+
props: DatabaseCommonInteractionProps = { isRoot: true },
|
|
197
|
+
): Promise<OnCreate<IncidentAlert>> {
|
|
198
|
+
const link: IncidentAlert = new IncidentAlert();
|
|
199
|
+
Object.assign(link, data);
|
|
200
|
+
return callHook<OnCreate<IncidentAlert>>("onBeforeCreate", {
|
|
201
|
+
data: link,
|
|
202
|
+
props: props,
|
|
203
|
+
} as CreateBy<IncidentAlert>);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
test("refuses a link with no incident", async () => {
|
|
207
|
+
await expect(
|
|
208
|
+
create({ projectId: PROJECT_ID, alertId: ALERT_ID }),
|
|
209
|
+
).rejects.toThrow("Please select the incident to link the alert to.");
|
|
210
|
+
expect(validator).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("refuses a link with no alert", async () => {
|
|
214
|
+
await expect(
|
|
215
|
+
create({ projectId: PROJECT_ID, incidentId: INCIDENT_ID }),
|
|
216
|
+
).rejects.toThrow("Please select the alert to link.");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("refuses a root link with no project to check the ends against", async () => {
|
|
220
|
+
await expect(
|
|
221
|
+
create({ incidentId: INCIDENT_ID, alertId: ALERT_ID }),
|
|
222
|
+
).rejects.toThrow("projectId is required");
|
|
223
|
+
expect(validator).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("refuses an incident id and incident relation that disagree", async () => {
|
|
227
|
+
await expect(
|
|
228
|
+
create({
|
|
229
|
+
projectId: PROJECT_ID,
|
|
230
|
+
incidentId: INCIDENT_ID,
|
|
231
|
+
incident: { _id: OTHER_INCIDENT_ID.toString() } as Incident,
|
|
232
|
+
alertId: ALERT_ID,
|
|
233
|
+
}),
|
|
234
|
+
).rejects.toThrow("Conflicting incident references were provided.");
|
|
235
|
+
expect(validator).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("refuses an alert id and alert relation that disagree", async () => {
|
|
239
|
+
await expect(
|
|
240
|
+
create({
|
|
241
|
+
projectId: PROJECT_ID,
|
|
242
|
+
incidentId: INCIDENT_ID,
|
|
243
|
+
alertId: ALERT_ID,
|
|
244
|
+
alert: { _id: ALERT_ID_2.toString() } as Alert,
|
|
245
|
+
}),
|
|
246
|
+
).rejects.toThrow("Conflicting alert references were provided.");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("accepts the relation spelling the dashboard posts and normalises it onto the id columns", async () => {
|
|
250
|
+
const result: OnCreate<IncidentAlert> = await create({
|
|
251
|
+
projectId: PROJECT_ID,
|
|
252
|
+
incident: { _id: INCIDENT_ID.toString() } as Incident,
|
|
253
|
+
alert: { _id: ALERT_ID.toString() } as Alert,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(result.createBy.data.incidentId?.toString()).toBe(
|
|
257
|
+
INCIDENT_ID.toString(),
|
|
258
|
+
);
|
|
259
|
+
expect(result.createBy.data.alertId?.toString()).toBe(ALERT_ID.toString());
|
|
260
|
+
expect(result.carryForward).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("a root link is checked against its project for both ends", async () => {
|
|
264
|
+
await create({
|
|
265
|
+
projectId: PROJECT_ID,
|
|
266
|
+
incidentId: INCIDENT_ID,
|
|
267
|
+
alertId: ALERT_ID,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(validator).toHaveBeenCalledTimes(1);
|
|
271
|
+
|
|
272
|
+
const args: {
|
|
273
|
+
projectId: ObjectID;
|
|
274
|
+
references: Array<{ modelName: string; id: ObjectID; service: unknown }>;
|
|
275
|
+
} = validator.mock.calls[0]![0];
|
|
276
|
+
|
|
277
|
+
expect(args.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
278
|
+
expect(
|
|
279
|
+
args.references.map(
|
|
280
|
+
(reference: { modelName: string; id: ObjectID; service: unknown }) => {
|
|
281
|
+
return [reference.modelName, reference.id.toString()];
|
|
282
|
+
},
|
|
283
|
+
),
|
|
284
|
+
).toEqual([
|
|
285
|
+
["Incident", INCIDENT_ID.toString()],
|
|
286
|
+
["Alert", ALERT_ID.toString()],
|
|
287
|
+
]);
|
|
288
|
+
expect(args.references[0]!.service).toBe(IncidentService);
|
|
289
|
+
expect(args.references[1]!.service).toBe(AlertService);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("a root link to another project's record is refused by the project check", async () => {
|
|
293
|
+
validator.mockRejectedValue(
|
|
294
|
+
new BadDataException(
|
|
295
|
+
"This incident alert link references records that belong to a different project",
|
|
296
|
+
) as never,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
await expect(
|
|
300
|
+
create({
|
|
301
|
+
projectId: PROJECT_ID,
|
|
302
|
+
incidentId: INCIDENT_ID,
|
|
303
|
+
alertId: ALERT_ID,
|
|
304
|
+
}),
|
|
305
|
+
).rejects.toThrow("belong to a different project");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a root link does not read the ends as a user", async () => {
|
|
309
|
+
const incidentRead: jest.SpyInstance = jest.spyOn(
|
|
310
|
+
IncidentService,
|
|
311
|
+
"findOneById",
|
|
312
|
+
);
|
|
313
|
+
const alertRead: jest.SpyInstance = jest.spyOn(AlertService, "findOneById");
|
|
314
|
+
|
|
315
|
+
await create({
|
|
316
|
+
projectId: PROJECT_ID,
|
|
317
|
+
incidentId: INCIDENT_ID,
|
|
318
|
+
alertId: ALERT_ID,
|
|
319
|
+
createdByUserId: OTHER_USER_ID,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(incidentRead).not.toHaveBeenCalled();
|
|
323
|
+
expect(alertRead).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("a root caller may name who linked the alert", async () => {
|
|
327
|
+
const result: OnCreate<IncidentAlert> = await create({
|
|
328
|
+
projectId: PROJECT_ID,
|
|
329
|
+
incidentId: INCIDENT_ID,
|
|
330
|
+
alertId: ALERT_ID,
|
|
331
|
+
createdByUserId: OTHER_USER_ID,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result.createBy.data.createdByUserId?.toString()).toBe(
|
|
335
|
+
OTHER_USER_ID.toString(),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("onBeforeCreate: a user may only link what they can see", () => {
|
|
341
|
+
let incidentRead: jest.SpyInstance;
|
|
342
|
+
let alertRead: jest.SpyInstance;
|
|
343
|
+
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
incidentRead = jest
|
|
346
|
+
.spyOn(IncidentService, "findOneById")
|
|
347
|
+
.mockResolvedValue(buildIncident() as never);
|
|
348
|
+
alertRead = jest
|
|
349
|
+
.spyOn(AlertService, "findOneById")
|
|
350
|
+
.mockResolvedValue(buildAlert() as never);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function create(
|
|
354
|
+
props: DatabaseCommonInteractionProps = userProps(Permission.ProjectMember),
|
|
355
|
+
data: Partial<IncidentAlert> = {},
|
|
356
|
+
): Promise<OnCreate<IncidentAlert>> {
|
|
357
|
+
return callHook<OnCreate<IncidentAlert>>("onBeforeCreate", {
|
|
358
|
+
data: buildLink(data),
|
|
359
|
+
props: props,
|
|
360
|
+
} as CreateBy<IncidentAlert>);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
test("reads both ends with the caller's own props", async () => {
|
|
364
|
+
const props: DatabaseCommonInteractionProps = userProps(
|
|
365
|
+
Permission.ProjectMember,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await create(props);
|
|
369
|
+
|
|
370
|
+
expect(incidentRead).toHaveBeenCalledWith(
|
|
371
|
+
expect.objectContaining({ id: INCIDENT_ID, props: props }),
|
|
372
|
+
);
|
|
373
|
+
expect(alertRead).toHaveBeenCalledWith(
|
|
374
|
+
expect.objectContaining({ id: ALERT_ID, props: props }),
|
|
375
|
+
);
|
|
376
|
+
expect(validator).toHaveBeenCalledTimes(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("the project is the caller's tenant, whatever the payload says", async () => {
|
|
380
|
+
const result: OnCreate<IncidentAlert> = await create(
|
|
381
|
+
userProps(Permission.ProjectMember),
|
|
382
|
+
{ projectId: OTHER_PROJECT_ID },
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
expect(result.createBy.data.projectId?.toString()).toBe(
|
|
386
|
+
PROJECT_ID.toString(),
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("an incident the caller cannot see is refused without saying why", async () => {
|
|
391
|
+
incidentRead.mockResolvedValue(null as never);
|
|
392
|
+
|
|
393
|
+
await expect(create()).rejects.toThrow(
|
|
394
|
+
"The incident to link does not exist in this project, or you do not have access to it.",
|
|
395
|
+
);
|
|
396
|
+
expect(validator).not.toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("an incident read the permission layer refuses gets the same answer", async () => {
|
|
400
|
+
incidentRead.mockRejectedValue(
|
|
401
|
+
new NotAuthorizedException(
|
|
402
|
+
"You do not have permission to read Incident",
|
|
403
|
+
) as never,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
await expect(create()).rejects.toThrow(
|
|
407
|
+
"The incident to link does not exist in this project, or you do not have access to it.",
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("an incident of another project is refused", async () => {
|
|
412
|
+
incidentRead.mockResolvedValue(buildIncident(OTHER_PROJECT_ID) as never);
|
|
413
|
+
|
|
414
|
+
await expect(create()).rejects.toThrow(
|
|
415
|
+
"The incident to link does not exist in this project",
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("an alert the caller cannot see is refused without saying why", async () => {
|
|
420
|
+
alertRead.mockResolvedValue(null as never);
|
|
421
|
+
|
|
422
|
+
await expect(create()).rejects.toThrow(
|
|
423
|
+
"The alert to link does not exist in this project, or you do not have access to it.",
|
|
424
|
+
);
|
|
425
|
+
expect(validator).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an alert read the permission layer refuses gets the same answer", async () => {
|
|
429
|
+
alertRead.mockRejectedValue(
|
|
430
|
+
new NotAuthorizedException(
|
|
431
|
+
"You do not have permission to read Alert",
|
|
432
|
+
) as never,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
await expect(create()).rejects.toThrow(
|
|
436
|
+
"The alert to link does not exist in this project, or you do not have access to it.",
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("an alert of another project is refused", async () => {
|
|
441
|
+
alertRead.mockResolvedValue(buildAlert(OTHER_PROJECT_ID) as never);
|
|
442
|
+
|
|
443
|
+
await expect(create()).rejects.toThrow(
|
|
444
|
+
"The alert to link does not exist in this project",
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a failure that is not about access is passed on unchanged", async () => {
|
|
449
|
+
alertRead.mockRejectedValue(new Error("connection reset") as never);
|
|
450
|
+
|
|
451
|
+
await expect(create()).rejects.toThrow("connection reset");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("the user who made the request is recorded as the one who linked it", async () => {
|
|
455
|
+
const result: OnCreate<IncidentAlert> = await create(
|
|
456
|
+
userProps(Permission.ProjectMember),
|
|
457
|
+
{
|
|
458
|
+
createdByUserId: OTHER_USER_ID,
|
|
459
|
+
createdByUser: { _id: OTHER_USER_ID.toString() } as User,
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
expect(result.createBy.data.createdByUserId?.toString()).toBe(
|
|
464
|
+
USER_ID.toString(),
|
|
465
|
+
);
|
|
466
|
+
expect(result.createBy.data.createdByUser).toBeUndefined();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("an API key cannot name somebody else as the one who linked it", async () => {
|
|
470
|
+
const result: OnCreate<IncidentAlert> = await create(
|
|
471
|
+
userProps(Permission.ProjectMember, null),
|
|
472
|
+
{ createdByUserId: OTHER_USER_ID },
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
expect(result.createBy.data.createdByUserId).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test.each([
|
|
479
|
+
Permission.ProjectMember,
|
|
480
|
+
Permission.IncidentMember,
|
|
481
|
+
Permission.AlertMember,
|
|
482
|
+
Permission.ProjectAdmin,
|
|
483
|
+
])(
|
|
484
|
+
"what the hook returns passes the create permission check for %s",
|
|
485
|
+
async (permission: Permission) => {
|
|
486
|
+
const props: DatabaseCommonInteractionProps = userProps(permission);
|
|
487
|
+
const result: OnCreate<IncidentAlert> = await create(props);
|
|
488
|
+
|
|
489
|
+
expect(() => {
|
|
490
|
+
ModelPermission.checkCreatePermissions(
|
|
491
|
+
IncidentAlert,
|
|
492
|
+
result.createBy.data,
|
|
493
|
+
result.createBy.props,
|
|
494
|
+
);
|
|
495
|
+
}).not.toThrow();
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("feed entries", () => {
|
|
501
|
+
let incidentFeed: jest.SpyInstance;
|
|
502
|
+
let alertFeed: jest.SpyInstance;
|
|
503
|
+
let sync: jest.SpyInstance;
|
|
504
|
+
|
|
505
|
+
beforeEach(() => {
|
|
506
|
+
const incident: Incident = new Incident();
|
|
507
|
+
incident.incidentNumber = 7;
|
|
508
|
+
incident.incidentNumberWithPrefix = "INC-7";
|
|
509
|
+
incident.title = "Checkout is down";
|
|
510
|
+
|
|
511
|
+
const alert: Alert = new Alert();
|
|
512
|
+
alert.alertNumber = 3;
|
|
513
|
+
alert.title = "Checkout p95 latency is high";
|
|
514
|
+
|
|
515
|
+
jest
|
|
516
|
+
.spyOn(IncidentService, "findOneById")
|
|
517
|
+
.mockResolvedValue(incident as never);
|
|
518
|
+
jest.spyOn(AlertService, "findOneById").mockResolvedValue(alert as never);
|
|
519
|
+
jest
|
|
520
|
+
.spyOn(IncidentService, "getIncidentLinkInDashboard")
|
|
521
|
+
.mockResolvedValue(URL.fromString(INCIDENT_URL) as never);
|
|
522
|
+
jest
|
|
523
|
+
.spyOn(AlertService, "getAlertLinkInDashboard")
|
|
524
|
+
.mockResolvedValue(URL.fromString(ALERT_URL) as never);
|
|
525
|
+
|
|
526
|
+
incidentFeed = jest
|
|
527
|
+
.spyOn(IncidentFeedService, "createIncidentFeedItem")
|
|
528
|
+
.mockResolvedValue(undefined as never);
|
|
529
|
+
alertFeed = jest
|
|
530
|
+
.spyOn(AlertFeedService, "createAlertFeedItem")
|
|
531
|
+
.mockResolvedValue(undefined as never);
|
|
532
|
+
sync = jest
|
|
533
|
+
.spyOn(IncidentAlertService, "syncAlertWithLinkedIncidentState")
|
|
534
|
+
.mockResolvedValue(undefined as never);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
function created(data: Partial<IncidentAlert> = {}): IncidentAlert {
|
|
538
|
+
const link: IncidentAlert = buildLink(data);
|
|
539
|
+
link._id = ObjectID.generate().toString();
|
|
540
|
+
return link;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
describe("on link", () => {
|
|
544
|
+
test("the incident gets an Alert Linked entry that is posted to its channels", async () => {
|
|
545
|
+
const link: IncidentAlert = created({ createdByUserId: USER_ID });
|
|
546
|
+
|
|
547
|
+
await IncidentAlertService.onCreateSuccess(
|
|
548
|
+
{
|
|
549
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
550
|
+
carryForward: null,
|
|
551
|
+
},
|
|
552
|
+
link,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
556
|
+
|
|
557
|
+
const entry: Record<string, unknown> = incidentFeed.mock.calls[0]![0];
|
|
558
|
+
|
|
559
|
+
expect(entry["incidentId"]).toBe(INCIDENT_ID);
|
|
560
|
+
expect(entry["projectId"]).toBe(PROJECT_ID);
|
|
561
|
+
expect(entry["incidentFeedEventType"]).toBe(
|
|
562
|
+
IncidentFeedEventType.AlertLinked,
|
|
563
|
+
);
|
|
564
|
+
expect(entry["displayColor"]).toBe(Yellow500);
|
|
565
|
+
expect(entry["userId"]).toBe(USER_ID);
|
|
566
|
+
expect(entry["workspaceNotification"]).toEqual({
|
|
567
|
+
sendWorkspaceNotification: true,
|
|
568
|
+
notifyUserId: USER_ID,
|
|
569
|
+
});
|
|
570
|
+
expect(entry["feedInfoInMarkdown"]).toBe(
|
|
571
|
+
`🔗 Linked **[Alert #3](${ALERT_URL})** to **[Incident INC-7](${INCIDENT_URL})**: Checkout p95 latency is high`,
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("the alert gets a Linked to Incident entry that is not posted to any channel", async () => {
|
|
576
|
+
const link: IncidentAlert = created({ createdByUserId: USER_ID });
|
|
577
|
+
|
|
578
|
+
await IncidentAlertService.onCreateSuccess(
|
|
579
|
+
{
|
|
580
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
581
|
+
carryForward: null,
|
|
582
|
+
},
|
|
583
|
+
link,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
587
|
+
|
|
588
|
+
const entry: Record<string, unknown> = alertFeed.mock.calls[0]![0];
|
|
589
|
+
|
|
590
|
+
expect(entry["alertId"]).toBe(ALERT_ID);
|
|
591
|
+
expect(entry["projectId"]).toBe(PROJECT_ID);
|
|
592
|
+
expect(entry["alertFeedEventType"]).toBe(
|
|
593
|
+
AlertFeedEventType.LinkedToIncident,
|
|
594
|
+
);
|
|
595
|
+
expect(entry["displayColor"]).toBe(Yellow500);
|
|
596
|
+
expect(entry["userId"]).toBe(USER_ID);
|
|
597
|
+
expect(entry["workspaceNotification"]).toBeUndefined();
|
|
598
|
+
expect(entry["feedInfoInMarkdown"]).toBe(
|
|
599
|
+
`🔗 Linked to **[Incident INC-7](${INCIDENT_URL})**: Checkout is down`,
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("the actor falls back to the requesting user when no linker was stored", async () => {
|
|
604
|
+
const link: IncidentAlert = created();
|
|
605
|
+
|
|
606
|
+
await IncidentAlertService.onCreateSuccess(
|
|
607
|
+
{
|
|
608
|
+
createBy: { data: link, props: { userId: OTHER_USER_ID } },
|
|
609
|
+
carryForward: null,
|
|
610
|
+
},
|
|
611
|
+
link,
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
expect(incidentFeed.mock.calls[0]![0]["userId"]).toBe(OTHER_USER_ID);
|
|
615
|
+
expect(alertFeed.mock.calls[0]![0]["userId"]).toBe(OTHER_USER_ID);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("the linked alert is brought in line with the incident's state", async () => {
|
|
619
|
+
const link: IncidentAlert = created();
|
|
620
|
+
|
|
621
|
+
await IncidentAlertService.onCreateSuccess(
|
|
622
|
+
{
|
|
623
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
624
|
+
carryForward: null,
|
|
625
|
+
},
|
|
626
|
+
link,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
expect(sync).toHaveBeenCalledWith({
|
|
630
|
+
projectId: PROJECT_ID,
|
|
631
|
+
incidentId: INCIDENT_ID,
|
|
632
|
+
alertId: ALERT_ID,
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("a failing state sync never fails the link", async () => {
|
|
637
|
+
sync.mockRejectedValue(new Error("sync failed") as never);
|
|
638
|
+
const link: IncidentAlert = created();
|
|
639
|
+
|
|
640
|
+
await expect(
|
|
641
|
+
IncidentAlertService.onCreateSuccess(
|
|
642
|
+
{
|
|
643
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
644
|
+
carryForward: null,
|
|
645
|
+
},
|
|
646
|
+
link,
|
|
647
|
+
),
|
|
648
|
+
).resolves.toBe(link);
|
|
649
|
+
|
|
650
|
+
// Let the fire-and-forget rejection settle into its catch.
|
|
651
|
+
await new Promise<void>((resolve: () => void) => {
|
|
652
|
+
setTimeout(resolve, 0);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
expect(logger.error).toHaveBeenCalled();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("a failing feed write never fails the link", async () => {
|
|
659
|
+
jest
|
|
660
|
+
.spyOn(IncidentService, "getIncidentLinkInDashboard")
|
|
661
|
+
.mockRejectedValue(new Error("no dashboard url") as never);
|
|
662
|
+
const link: IncidentAlert = created();
|
|
663
|
+
|
|
664
|
+
await expect(
|
|
665
|
+
IncidentAlertService.onCreateSuccess(
|
|
666
|
+
{
|
|
667
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
668
|
+
carryForward: null,
|
|
669
|
+
},
|
|
670
|
+
link,
|
|
671
|
+
),
|
|
672
|
+
).resolves.toBe(link);
|
|
673
|
+
expect(logger.error).toHaveBeenCalled();
|
|
674
|
+
expect(sync).toHaveBeenCalledTimes(1);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("a number with no prefix is shown as #<number>, and a missing title says so", async () => {
|
|
678
|
+
const incident: Incident = new Incident();
|
|
679
|
+
incident.incidentNumber = 12;
|
|
680
|
+
|
|
681
|
+
jest
|
|
682
|
+
.spyOn(IncidentService, "findOneById")
|
|
683
|
+
.mockResolvedValue(incident as never);
|
|
684
|
+
|
|
685
|
+
const link: IncidentAlert = created();
|
|
686
|
+
|
|
687
|
+
await IncidentAlertService.onCreateSuccess(
|
|
688
|
+
{
|
|
689
|
+
createBy: { data: link, props: { isRoot: true } },
|
|
690
|
+
carryForward: null,
|
|
691
|
+
},
|
|
692
|
+
link,
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
expect(alertFeed.mock.calls[0]![0]["feedInfoInMarkdown"]).toBe(
|
|
696
|
+
`🔗 Linked to **[Incident #12](${INCIDENT_URL})**: No title`,
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("on unlink", () => {
|
|
702
|
+
function carried(
|
|
703
|
+
id: ObjectID,
|
|
704
|
+
alertId: ObjectID,
|
|
705
|
+
): {
|
|
706
|
+
id: string;
|
|
707
|
+
incidentId: ObjectID;
|
|
708
|
+
alertId: ObjectID;
|
|
709
|
+
projectId: ObjectID;
|
|
710
|
+
} {
|
|
711
|
+
return {
|
|
712
|
+
id: id.toString(),
|
|
713
|
+
incidentId: INCIDENT_ID,
|
|
714
|
+
alertId: alertId,
|
|
715
|
+
projectId: PROJECT_ID,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
test("reports only the links that were actually deleted", async () => {
|
|
720
|
+
const deletedLinkId: ObjectID = ObjectID.generate();
|
|
721
|
+
const survivingLinkId: ObjectID = ObjectID.generate();
|
|
722
|
+
|
|
723
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
724
|
+
{
|
|
725
|
+
deleteBy: {
|
|
726
|
+
query: {},
|
|
727
|
+
props: { userId: USER_ID },
|
|
728
|
+
limit: 1,
|
|
729
|
+
skip: 0,
|
|
730
|
+
} as DeleteBy<IncidentAlert>,
|
|
731
|
+
carryForward: [
|
|
732
|
+
carried(deletedLinkId, ALERT_ID),
|
|
733
|
+
carried(survivingLinkId, ALERT_ID_2),
|
|
734
|
+
],
|
|
735
|
+
},
|
|
736
|
+
[new ObjectID(deletedLinkId.toString().toUpperCase())],
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
740
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
741
|
+
expect(alertFeed.mock.calls[0]![0]["alertId"]).toBe(ALERT_ID);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("an unlink writes Alert Unlinked (posted) and Unlinked from Incident (not posted)", async () => {
|
|
745
|
+
const linkId: ObjectID = ObjectID.generate();
|
|
746
|
+
|
|
747
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
748
|
+
{
|
|
749
|
+
deleteBy: {
|
|
750
|
+
query: {},
|
|
751
|
+
props: { userId: USER_ID },
|
|
752
|
+
limit: 1,
|
|
753
|
+
skip: 0,
|
|
754
|
+
} as DeleteBy<IncidentAlert>,
|
|
755
|
+
carryForward: [carried(linkId, ALERT_ID)],
|
|
756
|
+
},
|
|
757
|
+
[linkId],
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const incidentEntry: Record<string, unknown> =
|
|
761
|
+
incidentFeed.mock.calls[0]![0];
|
|
762
|
+
const alertEntry: Record<string, unknown> = alertFeed.mock.calls[0]![0];
|
|
763
|
+
|
|
764
|
+
expect(incidentEntry["incidentFeedEventType"]).toBe(
|
|
765
|
+
IncidentFeedEventType.AlertUnlinked,
|
|
766
|
+
);
|
|
767
|
+
expect(incidentEntry["displayColor"]).toBe(Gray500);
|
|
768
|
+
expect(incidentEntry["userId"]).toBe(USER_ID);
|
|
769
|
+
expect(incidentEntry["workspaceNotification"]).toEqual({
|
|
770
|
+
sendWorkspaceNotification: true,
|
|
771
|
+
notifyUserId: USER_ID,
|
|
772
|
+
});
|
|
773
|
+
expect(incidentEntry["feedInfoInMarkdown"]).toBe(
|
|
774
|
+
`Unlinked **[Alert #3](${ALERT_URL})** from **[Incident INC-7](${INCIDENT_URL})**: Checkout p95 latency is high`,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
expect(alertEntry["alertFeedEventType"]).toBe(
|
|
778
|
+
AlertFeedEventType.UnlinkedFromIncident,
|
|
779
|
+
);
|
|
780
|
+
expect(alertEntry["displayColor"]).toBe(Gray500);
|
|
781
|
+
expect(alertEntry["workspaceNotification"]).toBeUndefined();
|
|
782
|
+
expect(alertEntry["feedInfoInMarkdown"]).toBe(
|
|
783
|
+
`Unlinked from **[Incident INC-7](${INCIDENT_URL})**: Checkout is down`,
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("the actor is the user named as the deleter, else the requesting user", async () => {
|
|
788
|
+
const linkId: ObjectID = ObjectID.generate();
|
|
789
|
+
const deleter: User = new User();
|
|
790
|
+
deleter._id = OTHER_USER_ID.toString();
|
|
791
|
+
|
|
792
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
793
|
+
{
|
|
794
|
+
deleteBy: {
|
|
795
|
+
query: {},
|
|
796
|
+
deletedByUser: deleter,
|
|
797
|
+
props: { userId: USER_ID },
|
|
798
|
+
limit: 1,
|
|
799
|
+
skip: 0,
|
|
800
|
+
} as DeleteBy<IncidentAlert>,
|
|
801
|
+
carryForward: [carried(linkId, ALERT_ID)],
|
|
802
|
+
},
|
|
803
|
+
[linkId],
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
expect(
|
|
807
|
+
(incidentFeed.mock.calls[0]![0]["userId"] as ObjectID).toString(),
|
|
808
|
+
).toBe(OTHER_USER_ID.toString());
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("nothing is reported when nothing was deleted", async () => {
|
|
812
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
813
|
+
{
|
|
814
|
+
deleteBy: {
|
|
815
|
+
query: {},
|
|
816
|
+
props: { isRoot: true },
|
|
817
|
+
limit: 1,
|
|
818
|
+
skip: 0,
|
|
819
|
+
} as DeleteBy<IncidentAlert>,
|
|
820
|
+
carryForward: [carried(ObjectID.generate(), ALERT_ID)],
|
|
821
|
+
},
|
|
822
|
+
[],
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
expect(incidentFeed).not.toHaveBeenCalled();
|
|
826
|
+
expect(alertFeed).not.toHaveBeenCalled();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("a carry-forward that is not a list reports nothing", async () => {
|
|
830
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
831
|
+
{
|
|
832
|
+
deleteBy: {
|
|
833
|
+
query: {},
|
|
834
|
+
props: { isRoot: true },
|
|
835
|
+
limit: 1,
|
|
836
|
+
skip: 0,
|
|
837
|
+
} as DeleteBy<IncidentAlert>,
|
|
838
|
+
carryForward: null,
|
|
839
|
+
},
|
|
840
|
+
[ObjectID.generate()],
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
expect(incidentFeed).not.toHaveBeenCalled();
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
describe("a private end's title never reaches the other side's entry", () => {
|
|
848
|
+
const INCIDENT_TITLE: string = "Payroll export is failing";
|
|
849
|
+
const ALERT_TITLE: string = "Payroll DB credentials exposed";
|
|
850
|
+
|
|
851
|
+
let incidentRead: jest.SpyInstance;
|
|
852
|
+
let alertRead: jest.SpyInstance;
|
|
853
|
+
|
|
854
|
+
function stubEnds(ends: {
|
|
855
|
+
incidentIsPrivate: boolean;
|
|
856
|
+
alertIsPrivate: boolean;
|
|
857
|
+
}): void {
|
|
858
|
+
const incident: Incident = new Incident();
|
|
859
|
+
incident.incidentNumber = 7;
|
|
860
|
+
incident.incidentNumberWithPrefix = "INC-7";
|
|
861
|
+
incident.title = INCIDENT_TITLE;
|
|
862
|
+
incident.isPrivate = ends.incidentIsPrivate;
|
|
863
|
+
|
|
864
|
+
const alert: Alert = new Alert();
|
|
865
|
+
alert.alertNumber = 3;
|
|
866
|
+
alert.title = ALERT_TITLE;
|
|
867
|
+
alert.isPrivate = ends.alertIsPrivate;
|
|
868
|
+
|
|
869
|
+
incidentRead = jest
|
|
870
|
+
.spyOn(IncidentService, "findOneById")
|
|
871
|
+
.mockResolvedValue(incident as never);
|
|
872
|
+
alertRead = jest
|
|
873
|
+
.spyOn(AlertService, "findOneById")
|
|
874
|
+
.mockResolvedValue(alert as never);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function link(): Promise<void> {
|
|
878
|
+
const row: IncidentAlert = created({ createdByUserId: USER_ID });
|
|
879
|
+
|
|
880
|
+
await IncidentAlertService.onCreateSuccess(
|
|
881
|
+
{
|
|
882
|
+
createBy: { data: row, props: { isRoot: true } },
|
|
883
|
+
carryForward: null,
|
|
884
|
+
},
|
|
885
|
+
row,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function unlink(): Promise<void> {
|
|
890
|
+
const linkId: ObjectID = ObjectID.generate();
|
|
891
|
+
|
|
892
|
+
await IncidentAlertService.onDeleteSuccess(
|
|
893
|
+
{
|
|
894
|
+
deleteBy: {
|
|
895
|
+
query: {},
|
|
896
|
+
props: { userId: USER_ID },
|
|
897
|
+
limit: 1,
|
|
898
|
+
skip: 0,
|
|
899
|
+
} as DeleteBy<IncidentAlert>,
|
|
900
|
+
carryForward: [
|
|
901
|
+
{
|
|
902
|
+
id: linkId.toString(),
|
|
903
|
+
incidentId: INCIDENT_ID,
|
|
904
|
+
alertId: ALERT_ID,
|
|
905
|
+
projectId: PROJECT_ID,
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
},
|
|
909
|
+
[linkId],
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function incidentEntry(): Record<string, unknown> {
|
|
914
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
915
|
+
return incidentFeed.mock.calls[0]![0];
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function alertEntry(): Record<string, unknown> {
|
|
919
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
920
|
+
return alertFeed.mock.calls[0]![0];
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
test("both ends are read, as root, with their privacy", async () => {
|
|
924
|
+
stubEnds({ incidentIsPrivate: false, alertIsPrivate: false });
|
|
925
|
+
|
|
926
|
+
await link();
|
|
927
|
+
|
|
928
|
+
expect(incidentRead.mock.calls[0]![0].select).toEqual(
|
|
929
|
+
expect.objectContaining({ title: true, isPrivate: true }),
|
|
930
|
+
);
|
|
931
|
+
expect(alertRead.mock.calls[0]![0].select).toEqual(
|
|
932
|
+
expect.objectContaining({ title: true, isPrivate: true }),
|
|
933
|
+
);
|
|
934
|
+
expect(incidentRead.mock.calls[0]![0].props).toEqual({ isRoot: true });
|
|
935
|
+
expect(alertRead.mock.calls[0]![0].props).toEqual({ isRoot: true });
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test.each([
|
|
939
|
+
["link", link],
|
|
940
|
+
["unlink", unlink],
|
|
941
|
+
])(
|
|
942
|
+
"%s: a private alert is named by number and link only on the incident and its Slack / Teams post",
|
|
943
|
+
async (_label: string, act: () => Promise<void>) => {
|
|
944
|
+
stubEnds({ incidentIsPrivate: false, alertIsPrivate: true });
|
|
945
|
+
|
|
946
|
+
await act();
|
|
947
|
+
|
|
948
|
+
const entry: Record<string, unknown> = incidentEntry();
|
|
949
|
+
const markdown: string = entry["feedInfoInMarkdown"] as string;
|
|
950
|
+
|
|
951
|
+
expect(markdown).not.toContain(ALERT_TITLE);
|
|
952
|
+
expect(markdown).toContain(
|
|
953
|
+
`**[Alert #3](${ALERT_URL})** (private alert)`,
|
|
954
|
+
);
|
|
955
|
+
expect(markdown).toContain(`**[Incident INC-7](${INCIDENT_URL})**`);
|
|
956
|
+
// The post is this same markdown, so it is title-free too.
|
|
957
|
+
expect(entry["workspaceNotification"]).toEqual({
|
|
958
|
+
sendWorkspaceNotification: true,
|
|
959
|
+
notifyUserId: USER_ID,
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// The incident is not private: the alert's entry may name it.
|
|
963
|
+
expect(alertEntry()["feedInfoInMarkdown"]).toContain(
|
|
964
|
+
`: ${INCIDENT_TITLE}`,
|
|
965
|
+
);
|
|
966
|
+
},
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
test("link: the incident-side wording for a private alert", async () => {
|
|
970
|
+
stubEnds({ incidentIsPrivate: false, alertIsPrivate: true });
|
|
971
|
+
|
|
972
|
+
await link();
|
|
973
|
+
|
|
974
|
+
expect(incidentEntry()["feedInfoInMarkdown"]).toBe(
|
|
975
|
+
`🔗 Linked **[Alert #3](${ALERT_URL})** (private alert) to **[Incident INC-7](${INCIDENT_URL})**`,
|
|
976
|
+
);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("unlink: the incident-side wording for a private alert", async () => {
|
|
980
|
+
stubEnds({ incidentIsPrivate: false, alertIsPrivate: true });
|
|
981
|
+
|
|
982
|
+
await unlink();
|
|
983
|
+
|
|
984
|
+
expect(incidentEntry()["feedInfoInMarkdown"]).toBe(
|
|
985
|
+
`Unlinked **[Alert #3](${ALERT_URL})** (private alert) from **[Incident INC-7](${INCIDENT_URL})**`,
|
|
986
|
+
);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test.each([
|
|
990
|
+
[
|
|
991
|
+
"link",
|
|
992
|
+
link,
|
|
993
|
+
`🔗 Linked to **[Incident INC-7](${INCIDENT_URL})** (private incident)`,
|
|
994
|
+
],
|
|
995
|
+
[
|
|
996
|
+
"unlink",
|
|
997
|
+
unlink,
|
|
998
|
+
`Unlinked from **[Incident INC-7](${INCIDENT_URL})** (private incident)`,
|
|
999
|
+
],
|
|
1000
|
+
])(
|
|
1001
|
+
"%s: a private incident is named by number and link only on the alert",
|
|
1002
|
+
async (_label: string, act: () => Promise<void>, expected: string) => {
|
|
1003
|
+
stubEnds({ incidentIsPrivate: true, alertIsPrivate: false });
|
|
1004
|
+
|
|
1005
|
+
await act();
|
|
1006
|
+
|
|
1007
|
+
const markdown: string = alertEntry()["feedInfoInMarkdown"] as string;
|
|
1008
|
+
|
|
1009
|
+
expect(markdown).toBe(expected);
|
|
1010
|
+
expect(markdown).not.toContain(INCIDENT_TITLE);
|
|
1011
|
+
|
|
1012
|
+
// The alert is not private: the incident's entry may name it.
|
|
1013
|
+
expect(incidentEntry()["feedInfoInMarkdown"]).toContain(
|
|
1014
|
+
`: ${ALERT_TITLE}`,
|
|
1015
|
+
);
|
|
1016
|
+
},
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
test.each([
|
|
1020
|
+
["link", link],
|
|
1021
|
+
["unlink", unlink],
|
|
1022
|
+
])(
|
|
1023
|
+
"%s: when both ends are private neither title crosses over (their owners can differ)",
|
|
1024
|
+
async (_label: string, act: () => Promise<void>) => {
|
|
1025
|
+
stubEnds({ incidentIsPrivate: true, alertIsPrivate: true });
|
|
1026
|
+
|
|
1027
|
+
await act();
|
|
1028
|
+
|
|
1029
|
+
const incidentMarkdown: string = incidentEntry()[
|
|
1030
|
+
"feedInfoInMarkdown"
|
|
1031
|
+
] as string;
|
|
1032
|
+
const alertMarkdown: string = alertEntry()[
|
|
1033
|
+
"feedInfoInMarkdown"
|
|
1034
|
+
] as string;
|
|
1035
|
+
|
|
1036
|
+
expect(incidentMarkdown).not.toContain(ALERT_TITLE);
|
|
1037
|
+
expect(incidentMarkdown).toContain("(private alert)");
|
|
1038
|
+
expect(alertMarkdown).not.toContain(INCIDENT_TITLE);
|
|
1039
|
+
expect(alertMarkdown).toContain("(private incident)");
|
|
1040
|
+
},
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
test("public ends keep their titles on both sides", async () => {
|
|
1044
|
+
stubEnds({ incidentIsPrivate: false, alertIsPrivate: false });
|
|
1045
|
+
|
|
1046
|
+
await link();
|
|
1047
|
+
|
|
1048
|
+
expect(incidentEntry()["feedInfoInMarkdown"]).toBe(
|
|
1049
|
+
`🔗 Linked **[Alert #3](${ALERT_URL})** to **[Incident INC-7](${INCIDENT_URL})**: ${ALERT_TITLE}`,
|
|
1050
|
+
);
|
|
1051
|
+
expect(alertEntry()["feedInfoInMarkdown"]).toBe(
|
|
1052
|
+
`🔗 Linked to **[Incident INC-7](${INCIDENT_URL})**: ${INCIDENT_TITLE}`,
|
|
1053
|
+
);
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
describe("links written while an incident is declared from alerts", () => {
|
|
1058
|
+
async function linkWith(
|
|
1059
|
+
props: DatabaseCommonInteractionProps,
|
|
1060
|
+
miscDataProps: JSONObject | undefined,
|
|
1061
|
+
): Promise<void> {
|
|
1062
|
+
const row: IncidentAlert = created({ createdByUserId: USER_ID });
|
|
1063
|
+
|
|
1064
|
+
await IncidentAlertService.onCreateSuccess(
|
|
1065
|
+
{
|
|
1066
|
+
createBy: {
|
|
1067
|
+
data: row,
|
|
1068
|
+
props: props,
|
|
1069
|
+
...(miscDataProps ? { miscDataProps: miscDataProps } : {}),
|
|
1070
|
+
},
|
|
1071
|
+
carryForward: null,
|
|
1072
|
+
},
|
|
1073
|
+
row,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
test("root with the flag: no incident-side entry or post, the alert still gets its entry", async () => {
|
|
1078
|
+
await linkWith(
|
|
1079
|
+
{ isRoot: true },
|
|
1080
|
+
{ [INCIDENT_ALERT_DECLARED_WITH_INCIDENT_KEY]: true },
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
expect(incidentFeed).not.toHaveBeenCalled();
|
|
1084
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
1085
|
+
expect(alertFeed.mock.calls[0]![0]["alertFeedEventType"]).toBe(
|
|
1086
|
+
AlertFeedEventType.LinkedToIncident,
|
|
1087
|
+
);
|
|
1088
|
+
expect(alertFeed.mock.calls[0]![0]["userId"]).toBe(USER_ID);
|
|
1089
|
+
// The alert is still brought in line with the incident.
|
|
1090
|
+
expect(sync).toHaveBeenCalledTimes(1);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test.each([
|
|
1094
|
+
["a user", userProps(Permission.ProjectMember)],
|
|
1095
|
+
["an API key", userProps(Permission.ProjectMember, null)],
|
|
1096
|
+
])(
|
|
1097
|
+
"%s cannot suppress the incident's entry by sending the flag",
|
|
1098
|
+
async (_label: string, props: DatabaseCommonInteractionProps) => {
|
|
1099
|
+
await linkWith(props, {
|
|
1100
|
+
[INCIDENT_ALERT_DECLARED_WITH_INCIDENT_KEY]: true,
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
1104
|
+
|
|
1105
|
+
const entry: Record<string, unknown> = incidentFeed.mock.calls[0]![0];
|
|
1106
|
+
|
|
1107
|
+
expect(entry["incidentFeedEventType"]).toBe(
|
|
1108
|
+
IncidentFeedEventType.AlertLinked,
|
|
1109
|
+
);
|
|
1110
|
+
expect(entry["workspaceNotification"]).toEqual(
|
|
1111
|
+
expect.objectContaining({ sendWorkspaceNotification: true }),
|
|
1112
|
+
);
|
|
1113
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
1114
|
+
},
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
test.each([
|
|
1118
|
+
["no miscDataProps", undefined],
|
|
1119
|
+
["other miscDataProps", { somethingElse: true }],
|
|
1120
|
+
[
|
|
1121
|
+
"a flag that is not exactly true",
|
|
1122
|
+
{ [INCIDENT_ALERT_DECLARED_WITH_INCIDENT_KEY]: "true" },
|
|
1123
|
+
],
|
|
1124
|
+
])(
|
|
1125
|
+
"root with %s writes both entries",
|
|
1126
|
+
async (_label: string, miscDataProps: JSONObject | undefined) => {
|
|
1127
|
+
await linkWith({ isRoot: true }, miscDataProps);
|
|
1128
|
+
|
|
1129
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
1130
|
+
expect(alertFeed).toHaveBeenCalledTimes(1);
|
|
1131
|
+
},
|
|
1132
|
+
);
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
describe("onBeforeDelete carries the rows it may delete", () => {
|
|
1137
|
+
test("reads the matching rows as root, pinned to the caller's project", async () => {
|
|
1138
|
+
const row: IncidentAlert = buildLink();
|
|
1139
|
+
row._id = ObjectID.generate().toString();
|
|
1140
|
+
const incomplete: IncidentAlert = new IncidentAlert();
|
|
1141
|
+
incomplete._id = ObjectID.generate().toString();
|
|
1142
|
+
|
|
1143
|
+
const findBy: jest.SpyInstance = jest
|
|
1144
|
+
.spyOn(IncidentAlertService, "findBy")
|
|
1145
|
+
.mockResolvedValue([row, incomplete] as never);
|
|
1146
|
+
|
|
1147
|
+
const result: OnDelete<IncidentAlert> = await callHook<
|
|
1148
|
+
OnDelete<IncidentAlert>
|
|
1149
|
+
>("onBeforeDelete", {
|
|
1150
|
+
query: { _id: row._id },
|
|
1151
|
+
props: userProps(Permission.ProjectAdmin),
|
|
1152
|
+
limit: 1,
|
|
1153
|
+
skip: 0,
|
|
1154
|
+
} as DeleteBy<IncidentAlert>);
|
|
1155
|
+
|
|
1156
|
+
const args: {
|
|
1157
|
+
query: Query<IncidentAlert>;
|
|
1158
|
+
select: Record<string, boolean>;
|
|
1159
|
+
props: DatabaseCommonInteractionProps;
|
|
1160
|
+
} = findBy.mock.calls[0]![0];
|
|
1161
|
+
|
|
1162
|
+
expect(args.props).toEqual({ isRoot: true });
|
|
1163
|
+
expect(args.select).toEqual({
|
|
1164
|
+
_id: true,
|
|
1165
|
+
incidentId: true,
|
|
1166
|
+
alertId: true,
|
|
1167
|
+
projectId: true,
|
|
1168
|
+
});
|
|
1169
|
+
expect(args.query._id).toBe(row._id);
|
|
1170
|
+
expect(args.query.projectId).toBe(PROJECT_ID);
|
|
1171
|
+
|
|
1172
|
+
expect(result.carryForward).toEqual([
|
|
1173
|
+
{
|
|
1174
|
+
id: row._id,
|
|
1175
|
+
incidentId: INCIDENT_ID,
|
|
1176
|
+
alertId: ALERT_ID,
|
|
1177
|
+
projectId: PROJECT_ID,
|
|
1178
|
+
},
|
|
1179
|
+
]);
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
describe("privacy: link rows are narrowed to incidents AND alerts the caller can see", () => {
|
|
1184
|
+
function expectBothFilters(query: Query<IncidentAlert>): void {
|
|
1185
|
+
const incidentSql: Array<string> = operatorSql(query.incidentId);
|
|
1186
|
+
const alertSql: Array<string> = operatorSql(query.alertId);
|
|
1187
|
+
|
|
1188
|
+
expect(
|
|
1189
|
+
incidentSql.some((sql: string) => {
|
|
1190
|
+
return sql.includes('FROM "Incident" i');
|
|
1191
|
+
}),
|
|
1192
|
+
).toBe(true);
|
|
1193
|
+
expect(
|
|
1194
|
+
alertSql.some((sql: string) => {
|
|
1195
|
+
return sql.includes('FROM "Alert" a');
|
|
1196
|
+
}),
|
|
1197
|
+
).toBe(true);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
test("find", async () => {
|
|
1201
|
+
const result: OnFind<IncidentAlert> = await callHook<OnFind<IncidentAlert>>(
|
|
1202
|
+
"onBeforeFind",
|
|
1203
|
+
{
|
|
1204
|
+
query: {},
|
|
1205
|
+
props: userProps(Permission.ProjectMember),
|
|
1206
|
+
limit: 10,
|
|
1207
|
+
skip: 0,
|
|
1208
|
+
} as FindBy<IncidentAlert>,
|
|
1209
|
+
);
|
|
1210
|
+
|
|
1211
|
+
expectBothFilters(result.findBy.query);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test("find keeps the caller's own incident filter alongside the privacy clause", async () => {
|
|
1215
|
+
const result: OnFind<IncidentAlert> = await callHook<OnFind<IncidentAlert>>(
|
|
1216
|
+
"onBeforeFind",
|
|
1217
|
+
{
|
|
1218
|
+
query: { incidentId: INCIDENT_ID },
|
|
1219
|
+
props: userProps(Permission.ProjectMember),
|
|
1220
|
+
limit: 10,
|
|
1221
|
+
skip: 0,
|
|
1222
|
+
} as FindBy<IncidentAlert>,
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
const incidentFilter: FindOperator<unknown> = result.findBy.query
|
|
1226
|
+
.incidentId as unknown as FindOperator<unknown>;
|
|
1227
|
+
|
|
1228
|
+
expect(incidentFilter.type).toBe("and");
|
|
1229
|
+
expectBothFilters(result.findBy.query);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
test("count", async () => {
|
|
1233
|
+
const baseCount: jest.SpyInstance = jest
|
|
1234
|
+
.spyOn(DatabaseService.prototype, "countBy")
|
|
1235
|
+
.mockResolvedValue(new PositiveNumber(0) as never);
|
|
1236
|
+
|
|
1237
|
+
await IncidentAlertService.countBy({
|
|
1238
|
+
query: {},
|
|
1239
|
+
props: userProps(Permission.ProjectMember),
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
expect(baseCount).toHaveBeenCalledTimes(1);
|
|
1243
|
+
expectBothFilters(baseCount.mock.calls[0]![0].query);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("update", async () => {
|
|
1247
|
+
const result: OnUpdate<IncidentAlert> = await callHook<
|
|
1248
|
+
OnUpdate<IncidentAlert>
|
|
1249
|
+
>("onBeforeUpdate", {
|
|
1250
|
+
query: {},
|
|
1251
|
+
data: {},
|
|
1252
|
+
props: userProps(Permission.ProjectMember),
|
|
1253
|
+
limit: 1,
|
|
1254
|
+
skip: 0,
|
|
1255
|
+
} as UpdateBy<IncidentAlert>);
|
|
1256
|
+
|
|
1257
|
+
expectBothFilters(result.updateBy.query);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
test("delete, including the rows it carries forward", async () => {
|
|
1261
|
+
const findBy: jest.SpyInstance = jest
|
|
1262
|
+
.spyOn(IncidentAlertService, "findBy")
|
|
1263
|
+
.mockResolvedValue([] as never);
|
|
1264
|
+
|
|
1265
|
+
const result: OnDelete<IncidentAlert> = await callHook<
|
|
1266
|
+
OnDelete<IncidentAlert>
|
|
1267
|
+
>("onBeforeDelete", {
|
|
1268
|
+
query: {},
|
|
1269
|
+
props: userProps(Permission.ProjectMember),
|
|
1270
|
+
limit: 1,
|
|
1271
|
+
skip: 0,
|
|
1272
|
+
} as DeleteBy<IncidentAlert>);
|
|
1273
|
+
|
|
1274
|
+
expectBothFilters(result.deleteBy.query);
|
|
1275
|
+
expectBothFilters(findBy.mock.calls[0]![0].query);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("project admins, and root, are not narrowed", async () => {
|
|
1279
|
+
for (const props of [
|
|
1280
|
+
userProps(Permission.ProjectAdmin),
|
|
1281
|
+
{ isRoot: true },
|
|
1282
|
+
] as Array<DatabaseCommonInteractionProps>) {
|
|
1283
|
+
const result: OnFind<IncidentAlert> = await callHook<
|
|
1284
|
+
OnFind<IncidentAlert>
|
|
1285
|
+
>("onBeforeFind", {
|
|
1286
|
+
query: {},
|
|
1287
|
+
props: props,
|
|
1288
|
+
limit: 10,
|
|
1289
|
+
skip: 0,
|
|
1290
|
+
} as FindBy<IncidentAlert>);
|
|
1291
|
+
|
|
1292
|
+
expect(result.findBy.query).toEqual({});
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
describe("validateAlertIdsForNewIncident", () => {
|
|
1298
|
+
let alertFindBy: jest.SpyInstance;
|
|
1299
|
+
|
|
1300
|
+
beforeEach(() => {
|
|
1301
|
+
alertFindBy = jest
|
|
1302
|
+
.spyOn(AlertService, "findBy")
|
|
1303
|
+
.mockImplementation((async (args: {
|
|
1304
|
+
query: { _id: unknown };
|
|
1305
|
+
}): Promise<Array<Alert>> => {
|
|
1306
|
+
const ids: Array<string> = Object.values(
|
|
1307
|
+
(args.query._id as FindOperator<unknown>).objectLiteralParameters ||
|
|
1308
|
+
{},
|
|
1309
|
+
)[0] as Array<string>;
|
|
1310
|
+
return ids.map((id: string) => {
|
|
1311
|
+
return alertRow(new ObjectID(id));
|
|
1312
|
+
});
|
|
1313
|
+
}) as never);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
function validate(
|
|
1317
|
+
alertIds: unknown,
|
|
1318
|
+
props: DatabaseCommonInteractionProps = { isRoot: true },
|
|
1319
|
+
): Promise<Array<ObjectID>> {
|
|
1320
|
+
return IncidentAlertService.validateAlertIdsForNewIncident({
|
|
1321
|
+
projectId: PROJECT_ID,
|
|
1322
|
+
alertIds: alertIds,
|
|
1323
|
+
props: props,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
test.each([
|
|
1328
|
+
["a string", ALERT_ID.toString()],
|
|
1329
|
+
["an object", { alertId: ALERT_ID.toString() }],
|
|
1330
|
+
["a number", 5],
|
|
1331
|
+
])("refuses %s instead of a list", async (_label: string, value: unknown) => {
|
|
1332
|
+
await expect(validate(value)).rejects.toThrow(
|
|
1333
|
+
"alertIdsToLink must be an array of alert ids.",
|
|
1334
|
+
);
|
|
1335
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test.each([
|
|
1339
|
+
["a number", 42],
|
|
1340
|
+
["null", null],
|
|
1341
|
+
["an object", { _id: ALERT_ID.toString() }],
|
|
1342
|
+
["an empty string", ""],
|
|
1343
|
+
["a string that is not an id", "not-an-id"],
|
|
1344
|
+
["a SQL fragment", "1' OR '1'='1"],
|
|
1345
|
+
])("refuses a list holding %s", async (_label: string, value: unknown) => {
|
|
1346
|
+
await expect(validate([ALERT_ID.toString(), value])).rejects.toThrow(
|
|
1347
|
+
"alertIdsToLink must only contain alert ids.",
|
|
1348
|
+
);
|
|
1349
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("refuses an empty list", async () => {
|
|
1353
|
+
await expect(validate([])).rejects.toThrow(
|
|
1354
|
+
"Please select at least one alert to link to the incident.",
|
|
1355
|
+
);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
test(`refuses more than ${MAX_ALERTS_PER_INCIDENT_LINK_ACTION} distinct alerts`, async () => {
|
|
1359
|
+
const ids: Array<string> = [];
|
|
1360
|
+
for (let i: number = 0; i <= MAX_ALERTS_PER_INCIDENT_LINK_ACTION; i++) {
|
|
1361
|
+
ids.push(ObjectID.generate().toString());
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
await expect(validate(ids)).rejects.toThrow(
|
|
1365
|
+
`You can link at most ${MAX_ALERTS_PER_INCIDENT_LINK_ACTION} alerts`,
|
|
1366
|
+
);
|
|
1367
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test(`counts the limit after removing duplicates`, async () => {
|
|
1371
|
+
const ids: Array<string> = [];
|
|
1372
|
+
for (let i: number = 0; i < MAX_ALERTS_PER_INCIDENT_LINK_ACTION; i++) {
|
|
1373
|
+
ids.push(ObjectID.generate().toString());
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const result: Array<ObjectID> = await validate([...ids, ...ids]);
|
|
1377
|
+
|
|
1378
|
+
expect(result).toHaveLength(MAX_ALERTS_PER_INCIDENT_LINK_ACTION);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test("removes duplicates regardless of case and whitespace, keeping order", async () => {
|
|
1382
|
+
const result: Array<ObjectID> = await validate([
|
|
1383
|
+
ALERT_ID_2.toString(),
|
|
1384
|
+
` ${ALERT_ID.toString().toUpperCase()} `,
|
|
1385
|
+
ALERT_ID.toString(),
|
|
1386
|
+
ALERT_ID_2,
|
|
1387
|
+
]);
|
|
1388
|
+
|
|
1389
|
+
expect(
|
|
1390
|
+
result.map((id: ObjectID) => {
|
|
1391
|
+
return id.toString();
|
|
1392
|
+
}),
|
|
1393
|
+
).toEqual([ALERT_ID_2.toString(), ALERT_ID.toString()]);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("refuses when there is no project to check against", async () => {
|
|
1397
|
+
await expect(
|
|
1398
|
+
IncidentAlertService.validateAlertIdsForNewIncident({
|
|
1399
|
+
projectId: undefined,
|
|
1400
|
+
alertIds: [ALERT_ID.toString()],
|
|
1401
|
+
props: { isRoot: true },
|
|
1402
|
+
}),
|
|
1403
|
+
).rejects.toThrow("projectId is required");
|
|
1404
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("root: reads the alerts as root, pinned to the project", async () => {
|
|
1408
|
+
await validate([ALERT_ID.toString(), ALERT_ID_2.toString()]);
|
|
1409
|
+
|
|
1410
|
+
const args: {
|
|
1411
|
+
query: Query<Alert>;
|
|
1412
|
+
props: DatabaseCommonInteractionProps;
|
|
1413
|
+
} = alertFindBy.mock.calls[0]![0];
|
|
1414
|
+
|
|
1415
|
+
expect(args.props).toEqual({ isRoot: true });
|
|
1416
|
+
expect(args.query.projectId).toBe(PROJECT_ID);
|
|
1417
|
+
expect(
|
|
1418
|
+
Object.values(
|
|
1419
|
+
(args.query._id as unknown as FindOperator<unknown>)
|
|
1420
|
+
.objectLiteralParameters || {},
|
|
1421
|
+
)[0],
|
|
1422
|
+
).toEqual([ALERT_ID.toString(), ALERT_ID_2.toString()]);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test("refuses when an alert does not exist in the project", async () => {
|
|
1426
|
+
alertFindBy.mockResolvedValue([alertRow(ALERT_ID)] as never);
|
|
1427
|
+
|
|
1428
|
+
await expect(
|
|
1429
|
+
validate([ALERT_ID.toString(), ALERT_ID_3.toString()]),
|
|
1430
|
+
).rejects.toThrow(
|
|
1431
|
+
"One or more of the selected alerts do not exist in this project, or you do not have access to them.",
|
|
1432
|
+
);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
test("a user reads the alerts as themselves", async () => {
|
|
1436
|
+
const props: DatabaseCommonInteractionProps = userProps(
|
|
1437
|
+
Permission.ProjectMember,
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
const result: Array<ObjectID> = await validate(
|
|
1441
|
+
[ALERT_ID.toString()],
|
|
1442
|
+
props,
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
expect(alertFindBy.mock.calls[0]![0].props).toBe(props);
|
|
1446
|
+
expect(result.map(String)).toEqual([ALERT_ID.toString()]);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
test("a user who cannot see one of the alerts is refused without saying which", async () => {
|
|
1450
|
+
alertFindBy.mockResolvedValue([alertRow(ALERT_ID)] as never);
|
|
1451
|
+
|
|
1452
|
+
await expect(
|
|
1453
|
+
validate(
|
|
1454
|
+
[ALERT_ID.toString(), ALERT_ID_2.toString()],
|
|
1455
|
+
userProps(Permission.ProjectMember),
|
|
1456
|
+
),
|
|
1457
|
+
).rejects.toThrow(
|
|
1458
|
+
"One or more of the selected alerts do not exist in this project, or you do not have access to them.",
|
|
1459
|
+
);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
test("a user without read access to alerts gets the same answer", async () => {
|
|
1463
|
+
alertFindBy.mockRejectedValue(
|
|
1464
|
+
new NotAuthorizedException(
|
|
1465
|
+
"You do not have permission to read Alert",
|
|
1466
|
+
) as never,
|
|
1467
|
+
);
|
|
1468
|
+
|
|
1469
|
+
await expect(
|
|
1470
|
+
validate([ALERT_ID.toString()], userProps(Permission.IncidentMember)),
|
|
1471
|
+
).rejects.toThrow(
|
|
1472
|
+
"One or more of the selected alerts do not exist in this project, or you do not have access to them.",
|
|
1473
|
+
);
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test.each([
|
|
1477
|
+
Permission.Viewer,
|
|
1478
|
+
Permission.IncidentViewer,
|
|
1479
|
+
Permission.AlertViewer,
|
|
1480
|
+
])(
|
|
1481
|
+
"a %s, who may not create links, is refused before any alert is read",
|
|
1482
|
+
async (permission: Permission) => {
|
|
1483
|
+
await expect(
|
|
1484
|
+
validate([ALERT_ID.toString()], userProps(permission)),
|
|
1485
|
+
).rejects.toThrow(
|
|
1486
|
+
"You do not have permission to link alerts to incidents in this project.",
|
|
1487
|
+
);
|
|
1488
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1489
|
+
},
|
|
1490
|
+
);
|
|
1491
|
+
|
|
1492
|
+
test("a refusal that is not about the caller's role keeps its own answer", async () => {
|
|
1493
|
+
jest
|
|
1494
|
+
.spyOn(ModelPermission, "checkCreatePermissions")
|
|
1495
|
+
.mockImplementation((() => {
|
|
1496
|
+
throw new PaymentRequiredException("Please upgrade your plan");
|
|
1497
|
+
}) as never);
|
|
1498
|
+
|
|
1499
|
+
await expect(
|
|
1500
|
+
validate([ALERT_ID.toString()], userProps(Permission.ProjectMember)),
|
|
1501
|
+
).rejects.toBeInstanceOf(PaymentRequiredException);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
test.each([
|
|
1505
|
+
Permission.ProjectMember,
|
|
1506
|
+
Permission.IncidentMember,
|
|
1507
|
+
Permission.AlertMember,
|
|
1508
|
+
])("a %s may link alerts", async (permission: Permission) => {
|
|
1509
|
+
await expect(
|
|
1510
|
+
validate([ALERT_ID.toString()], userProps(permission)),
|
|
1511
|
+
).resolves.toHaveLength(1);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("accepts ObjectID values from server-side callers", async () => {
|
|
1515
|
+
const result: Array<ObjectID> = await validate([ALERT_ID]);
|
|
1516
|
+
|
|
1517
|
+
expect(result.map(String)).toEqual([ALERT_ID.toString()]);
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
describe("linkAlertsToIncident", () => {
|
|
1522
|
+
test("links each alert, counts an existing link as done and reports other failures", async () => {
|
|
1523
|
+
const create: jest.SpyInstance = jest
|
|
1524
|
+
.spyOn(IncidentAlertService, "create")
|
|
1525
|
+
.mockImplementation((async (args: {
|
|
1526
|
+
data: IncidentAlert;
|
|
1527
|
+
}): Promise<IncidentAlert> => {
|
|
1528
|
+
if (args.data.alertId?.toString() === ALERT_ID_2.toString()) {
|
|
1529
|
+
throw PostgresErrorTranslator.createUniqueViolationException(
|
|
1530
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (args.data.alertId?.toString() === ALERT_ID_3.toString()) {
|
|
1535
|
+
throw new BadDataException("The alert to link does not exist");
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
return args.data;
|
|
1539
|
+
}) as never);
|
|
1540
|
+
|
|
1541
|
+
const result: LinkAlertsToIncidentResult =
|
|
1542
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
1543
|
+
projectId: PROJECT_ID,
|
|
1544
|
+
incidentId: INCIDENT_ID,
|
|
1545
|
+
alertIds: [ALERT_ID, ALERT_ID_2, ALERT_ID_3],
|
|
1546
|
+
createdByUserId: USER_ID,
|
|
1547
|
+
props: { isRoot: true },
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
expect(result.linkedAlertIds).toEqual([ALERT_ID]);
|
|
1551
|
+
expect(result.alreadyLinkedAlertIds).toEqual([ALERT_ID_2]);
|
|
1552
|
+
expect(result.failed).toEqual([
|
|
1553
|
+
{ alertId: ALERT_ID_3, message: "The alert to link does not exist" },
|
|
1554
|
+
]);
|
|
1555
|
+
|
|
1556
|
+
// Every alert was attempted, in order, even after a failure.
|
|
1557
|
+
expect(create).toHaveBeenCalledTimes(3);
|
|
1558
|
+
|
|
1559
|
+
const first: {
|
|
1560
|
+
data: IncidentAlert;
|
|
1561
|
+
props: DatabaseCommonInteractionProps;
|
|
1562
|
+
} = create.mock.calls[0]![0];
|
|
1563
|
+
|
|
1564
|
+
expect(first.props).toEqual({ isRoot: true });
|
|
1565
|
+
expect(first.data.projectId).toBe(PROJECT_ID);
|
|
1566
|
+
expect(first.data.incidentId).toBe(INCIDENT_ID);
|
|
1567
|
+
expect(first.data.alertId).toBe(ALERT_ID);
|
|
1568
|
+
expect(first.data.createdByUserId).toBe(USER_ID);
|
|
1569
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("a unique violation raised by Postgres itself also counts as already linked", async () => {
|
|
1573
|
+
const driverError: Error & { code?: string; driverError?: unknown } =
|
|
1574
|
+
new Error("duplicate key value violates unique constraint");
|
|
1575
|
+
driverError.driverError = { code: "23505" };
|
|
1576
|
+
driverError.code = "23505";
|
|
1577
|
+
|
|
1578
|
+
jest
|
|
1579
|
+
.spyOn(IncidentAlertService, "create")
|
|
1580
|
+
.mockRejectedValue(driverError as never);
|
|
1581
|
+
|
|
1582
|
+
const result: LinkAlertsToIncidentResult =
|
|
1583
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
1584
|
+
projectId: PROJECT_ID,
|
|
1585
|
+
incidentId: INCIDENT_ID,
|
|
1586
|
+
alertIds: [ALERT_ID],
|
|
1587
|
+
props: { isRoot: true },
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
expect(result.alreadyLinkedAlertIds).toEqual([ALERT_ID]);
|
|
1591
|
+
expect(result.failed).toEqual([]);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
test("leaves who linked it to the create when no user is named", async () => {
|
|
1595
|
+
const create: jest.SpyInstance = jest
|
|
1596
|
+
.spyOn(IncidentAlertService, "create")
|
|
1597
|
+
.mockImplementation((async (args: {
|
|
1598
|
+
data: IncidentAlert;
|
|
1599
|
+
}): Promise<IncidentAlert> => {
|
|
1600
|
+
return args.data;
|
|
1601
|
+
}) as never);
|
|
1602
|
+
|
|
1603
|
+
const props: DatabaseCommonInteractionProps = userProps(
|
|
1604
|
+
Permission.ProjectMember,
|
|
1605
|
+
);
|
|
1606
|
+
|
|
1607
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
1608
|
+
projectId: PROJECT_ID,
|
|
1609
|
+
incidentId: INCIDENT_ID,
|
|
1610
|
+
alertIds: [ALERT_ID],
|
|
1611
|
+
props: props,
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
expect(create.mock.calls[0]![0].data.createdByUserId).toBeUndefined();
|
|
1615
|
+
expect(create.mock.calls[0]![0].props).toBe(props);
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
describe("linkAlertsToIncident while an incident is declared", () => {
|
|
1620
|
+
let create: jest.SpyInstance;
|
|
1621
|
+
|
|
1622
|
+
beforeEach(() => {
|
|
1623
|
+
create = jest
|
|
1624
|
+
.spyOn(IncidentAlertService, "create")
|
|
1625
|
+
.mockImplementation((async (args: {
|
|
1626
|
+
data: IncidentAlert;
|
|
1627
|
+
}): Promise<IncidentAlert> => {
|
|
1628
|
+
return args.data;
|
|
1629
|
+
}) as never);
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
test("marks every link as declared with the incident", async () => {
|
|
1633
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
1634
|
+
projectId: PROJECT_ID,
|
|
1635
|
+
incidentId: INCIDENT_ID,
|
|
1636
|
+
alertIds: [ALERT_ID, ALERT_ID_2],
|
|
1637
|
+
declaredWithIncident: true,
|
|
1638
|
+
props: { isRoot: true },
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
expect(create).toHaveBeenCalledTimes(2);
|
|
1642
|
+
|
|
1643
|
+
for (const call of create.mock.calls) {
|
|
1644
|
+
expect(call[0].miscDataProps).toEqual({
|
|
1645
|
+
[INCIDENT_ALERT_DECLARED_WITH_INCIDENT_KEY]: true,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
test.each([
|
|
1651
|
+
["left out", undefined],
|
|
1652
|
+
["false", false],
|
|
1653
|
+
])(
|
|
1654
|
+
"sends no miscDataProps when declaredWithIncident is %s",
|
|
1655
|
+
async (_label: string, declaredWithIncident: boolean | undefined) => {
|
|
1656
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
1657
|
+
projectId: PROJECT_ID,
|
|
1658
|
+
incidentId: INCIDENT_ID,
|
|
1659
|
+
alertIds: [ALERT_ID],
|
|
1660
|
+
declaredWithIncident: declaredWithIncident,
|
|
1661
|
+
props: { isRoot: true },
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
expect(create.mock.calls[0]![0]).not.toHaveProperty("miscDataProps");
|
|
1665
|
+
},
|
|
1666
|
+
);
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
describe("the one incident entry for an incident declared from alerts", () => {
|
|
1670
|
+
function mention(
|
|
1671
|
+
number: number,
|
|
1672
|
+
title: string | undefined,
|
|
1673
|
+
isPrivate: boolean = false,
|
|
1674
|
+
): LinkedAlertMention {
|
|
1675
|
+
return {
|
|
1676
|
+
label: `Alert #${number}`,
|
|
1677
|
+
link: `${ALERT_URL}/${number}`,
|
|
1678
|
+
title: title,
|
|
1679
|
+
isPrivate: isPrivate,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
test("lists every alert with its number and link, and the title of each public one", () => {
|
|
1684
|
+
expect(
|
|
1685
|
+
getDeclaredFromAlertsMarkdown([
|
|
1686
|
+
mention(3, "Checkout p95 latency is high"),
|
|
1687
|
+
mention(7, "Payroll DB credentials exposed", true),
|
|
1688
|
+
mention(9, undefined),
|
|
1689
|
+
]),
|
|
1690
|
+
).toBe(
|
|
1691
|
+
[
|
|
1692
|
+
"🔗 Declared from 3 alerts:",
|
|
1693
|
+
"",
|
|
1694
|
+
`- **[Alert #3](${ALERT_URL}/3)**: Checkout p95 latency is high`,
|
|
1695
|
+
`- **[Alert #7](${ALERT_URL}/7)** (private alert)`,
|
|
1696
|
+
`- **[Alert #9](${ALERT_URL}/9)**: No title`,
|
|
1697
|
+
].join("\n"),
|
|
1698
|
+
);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
test("one alert is 'alert', not 'alerts'", () => {
|
|
1702
|
+
expect(
|
|
1703
|
+
getDeclaredFromAlertsMarkdown([mention(3, "Checkout is slow")]),
|
|
1704
|
+
).toBe(
|
|
1705
|
+
`🔗 Declared from 1 alert:\n\n- **[Alert #3](${ALERT_URL}/3)**: Checkout is slow`,
|
|
1706
|
+
);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
describe("reading the alerts and posting the entry", () => {
|
|
1710
|
+
let alertFindBy: jest.SpyInstance;
|
|
1711
|
+
let incidentFeed: jest.SpyInstance;
|
|
1712
|
+
|
|
1713
|
+
function alertWith(
|
|
1714
|
+
id: ObjectID,
|
|
1715
|
+
number: number,
|
|
1716
|
+
title: string,
|
|
1717
|
+
options: { isPrivate?: boolean; prefix?: string } = {},
|
|
1718
|
+
): Alert {
|
|
1719
|
+
const alert: Alert = new Alert();
|
|
1720
|
+
alert._id = id.toString();
|
|
1721
|
+
alert.alertNumber = number;
|
|
1722
|
+
alert.title = title;
|
|
1723
|
+
alert.isPrivate = options.isPrivate === true;
|
|
1724
|
+
if (options.prefix) {
|
|
1725
|
+
alert.alertNumberWithPrefix = `${options.prefix}${number}`;
|
|
1726
|
+
}
|
|
1727
|
+
return alert;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
beforeEach(() => {
|
|
1731
|
+
// Returned out of order: the entry follows the order it was given.
|
|
1732
|
+
alertFindBy = jest.spyOn(AlertService, "findBy").mockResolvedValue([
|
|
1733
|
+
alertWith(ALERT_ID_3, 9, "Disk is full"),
|
|
1734
|
+
alertWith(ALERT_ID, 3, "Checkout p95 latency is high", {
|
|
1735
|
+
prefix: "ALT-",
|
|
1736
|
+
}),
|
|
1737
|
+
alertWith(ALERT_ID_2, 7, "Payroll DB credentials exposed", {
|
|
1738
|
+
isPrivate: true,
|
|
1739
|
+
}),
|
|
1740
|
+
] as never);
|
|
1741
|
+
|
|
1742
|
+
jest
|
|
1743
|
+
.spyOn(AlertService, "getAlertLinkInDashboard")
|
|
1744
|
+
.mockImplementation((async (
|
|
1745
|
+
_projectId: ObjectID,
|
|
1746
|
+
alertId: ObjectID,
|
|
1747
|
+
): Promise<URL> => {
|
|
1748
|
+
return URL.fromString(`${ALERT_URL}/${alertId.toString()}`);
|
|
1749
|
+
}) as never);
|
|
1750
|
+
|
|
1751
|
+
incidentFeed = jest
|
|
1752
|
+
.spyOn(IncidentFeedService, "createIncidentFeedItem")
|
|
1753
|
+
.mockResolvedValue(undefined as never);
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
test("reads the alerts as root, pinned to the project, with their privacy", async () => {
|
|
1757
|
+
await IncidentAlertService.buildDeclaredFromAlertsMarkdown({
|
|
1758
|
+
projectId: PROJECT_ID,
|
|
1759
|
+
alertIds: [ALERT_ID, ALERT_ID_2],
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
const args: {
|
|
1763
|
+
query: Query<Alert>;
|
|
1764
|
+
select: Record<string, boolean>;
|
|
1765
|
+
props: DatabaseCommonInteractionProps;
|
|
1766
|
+
} = alertFindBy.mock.calls[0]![0];
|
|
1767
|
+
|
|
1768
|
+
expect(args.props).toEqual({ isRoot: true });
|
|
1769
|
+
expect(args.query.projectId).toBe(PROJECT_ID);
|
|
1770
|
+
expect(
|
|
1771
|
+
Object.values(
|
|
1772
|
+
(args.query._id as unknown as FindOperator<unknown>)
|
|
1773
|
+
.objectLiteralParameters || {},
|
|
1774
|
+
)[0],
|
|
1775
|
+
).toEqual([ALERT_ID.toString(), ALERT_ID_2.toString()]);
|
|
1776
|
+
expect(args.select).toEqual(
|
|
1777
|
+
expect.objectContaining({
|
|
1778
|
+
alertNumber: true,
|
|
1779
|
+
alertNumberWithPrefix: true,
|
|
1780
|
+
title: true,
|
|
1781
|
+
isPrivate: true,
|
|
1782
|
+
}),
|
|
1783
|
+
);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
test("lists the alerts in the order given, once each, skipping ones that no longer exist", async () => {
|
|
1787
|
+
const gone: ObjectID = ObjectID.generate();
|
|
1788
|
+
|
|
1789
|
+
const markdown: string | null =
|
|
1790
|
+
await IncidentAlertService.buildDeclaredFromAlertsMarkdown({
|
|
1791
|
+
projectId: PROJECT_ID,
|
|
1792
|
+
alertIds: [ALERT_ID_2, gone, ALERT_ID, ALERT_ID_3, ALERT_ID],
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
expect(markdown).toBe(
|
|
1796
|
+
[
|
|
1797
|
+
"🔗 Declared from 3 alerts:",
|
|
1798
|
+
"",
|
|
1799
|
+
`- **[Alert #7](${ALERT_URL}/${ALERT_ID_2.toString()})** (private alert)`,
|
|
1800
|
+
`- **[Alert ALT-3](${ALERT_URL}/${ALERT_ID.toString()})**: Checkout p95 latency is high`,
|
|
1801
|
+
`- **[Alert #9](${ALERT_URL}/${ALERT_ID_3.toString()})**: Disk is full`,
|
|
1802
|
+
].join("\n"),
|
|
1803
|
+
);
|
|
1804
|
+
expect(markdown).not.toContain("Payroll DB credentials exposed");
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("no alerts to name: no markdown, and nothing is read", async () => {
|
|
1808
|
+
await expect(
|
|
1809
|
+
IncidentAlertService.buildDeclaredFromAlertsMarkdown({
|
|
1810
|
+
projectId: PROJECT_ID,
|
|
1811
|
+
alertIds: [],
|
|
1812
|
+
}),
|
|
1813
|
+
).resolves.toBeNull();
|
|
1814
|
+
expect(alertFindBy).not.toHaveBeenCalled();
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
test("posts exactly one Alert Linked entry to the incident and its channels", async () => {
|
|
1818
|
+
await IncidentAlertService.createDeclaredFromAlertsFeedItem({
|
|
1819
|
+
projectId: PROJECT_ID,
|
|
1820
|
+
incidentId: INCIDENT_ID,
|
|
1821
|
+
alertIds: [ALERT_ID, ALERT_ID_2, ALERT_ID_3],
|
|
1822
|
+
actorUserId: USER_ID,
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
expect(incidentFeed).toHaveBeenCalledTimes(1);
|
|
1826
|
+
|
|
1827
|
+
const entry: Record<string, unknown> = incidentFeed.mock.calls[0]![0];
|
|
1828
|
+
|
|
1829
|
+
expect(entry["incidentId"]).toBe(INCIDENT_ID);
|
|
1830
|
+
expect(entry["projectId"]).toBe(PROJECT_ID);
|
|
1831
|
+
expect(entry["incidentFeedEventType"]).toBe(
|
|
1832
|
+
IncidentFeedEventType.AlertLinked,
|
|
1833
|
+
);
|
|
1834
|
+
expect(entry["displayColor"]).toBe(Yellow500);
|
|
1835
|
+
expect(entry["userId"]).toBe(USER_ID);
|
|
1836
|
+
expect(entry["workspaceNotification"]).toEqual({
|
|
1837
|
+
sendWorkspaceNotification: true,
|
|
1838
|
+
notifyUserId: USER_ID,
|
|
1839
|
+
});
|
|
1840
|
+
expect(entry["feedInfoInMarkdown"]).toContain(
|
|
1841
|
+
"🔗 Declared from 3 alerts:",
|
|
1842
|
+
);
|
|
1843
|
+
expect(entry["feedInfoInMarkdown"]).not.toContain(
|
|
1844
|
+
"Payroll DB credentials exposed",
|
|
1845
|
+
);
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
test("an API key declares as nobody", async () => {
|
|
1849
|
+
await IncidentAlertService.createDeclaredFromAlertsFeedItem({
|
|
1850
|
+
projectId: PROJECT_ID,
|
|
1851
|
+
incidentId: INCIDENT_ID,
|
|
1852
|
+
alertIds: [ALERT_ID],
|
|
1853
|
+
actorUserId: undefined,
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
const entry: Record<string, unknown> = incidentFeed.mock.calls[0]![0];
|
|
1857
|
+
|
|
1858
|
+
expect(entry["userId"]).toBeUndefined();
|
|
1859
|
+
expect(entry["workspaceNotification"]).toEqual({
|
|
1860
|
+
sendWorkspaceNotification: true,
|
|
1861
|
+
notifyUserId: undefined,
|
|
1862
|
+
});
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
test("posts nothing when none of the alerts exists any more", async () => {
|
|
1866
|
+
alertFindBy.mockResolvedValue([] as never);
|
|
1867
|
+
|
|
1868
|
+
await IncidentAlertService.createDeclaredFromAlertsFeedItem({
|
|
1869
|
+
projectId: PROJECT_ID,
|
|
1870
|
+
incidentId: INCIDENT_ID,
|
|
1871
|
+
alertIds: [ALERT_ID],
|
|
1872
|
+
actorUserId: USER_ID,
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
expect(incidentFeed).not.toHaveBeenCalled();
|
|
1876
|
+
});
|
|
1877
|
+
});
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
describe("copyAlertOwnersToIncident", () => {
|
|
1881
|
+
const TEAM_ID: ObjectID = new ObjectID(
|
|
1882
|
+
"0194a1e7-0000-4000-8000-0000000000d1",
|
|
1883
|
+
);
|
|
1884
|
+
const OTHER_TEAM_ID: ObjectID = new ObjectID(
|
|
1885
|
+
"0194a1e7-0000-4000-8000-0000000000d2",
|
|
1886
|
+
);
|
|
1887
|
+
|
|
1888
|
+
let ownerUsers: jest.SpyInstance;
|
|
1889
|
+
let ownerTeams: jest.SpyInstance;
|
|
1890
|
+
let addOwners: jest.SpyInstance;
|
|
1891
|
+
|
|
1892
|
+
function ownerUser(userId: ObjectID | undefined): AlertOwnerUser {
|
|
1893
|
+
const owner: AlertOwnerUser = new AlertOwnerUser();
|
|
1894
|
+
if (userId) {
|
|
1895
|
+
owner.userId = userId;
|
|
1896
|
+
}
|
|
1897
|
+
return owner;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function ownerTeam(teamId: ObjectID): AlertOwnerTeam {
|
|
1901
|
+
const owner: AlertOwnerTeam = new AlertOwnerTeam();
|
|
1902
|
+
owner.teamId = teamId;
|
|
1903
|
+
return owner;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
beforeEach(() => {
|
|
1907
|
+
// Two alerts owned by overlapping users and teams.
|
|
1908
|
+
ownerUsers = jest
|
|
1909
|
+
.spyOn(AlertOwnerUserService, "findBy")
|
|
1910
|
+
.mockResolvedValue([
|
|
1911
|
+
ownerUser(USER_ID),
|
|
1912
|
+
ownerUser(OTHER_USER_ID),
|
|
1913
|
+
ownerUser(new ObjectID(USER_ID.toString().toUpperCase())),
|
|
1914
|
+
ownerUser(undefined),
|
|
1915
|
+
] as never);
|
|
1916
|
+
ownerTeams = jest
|
|
1917
|
+
.spyOn(AlertOwnerTeamService, "findBy")
|
|
1918
|
+
.mockResolvedValue([
|
|
1919
|
+
ownerTeam(TEAM_ID),
|
|
1920
|
+
ownerTeam(OTHER_TEAM_ID),
|
|
1921
|
+
ownerTeam(TEAM_ID),
|
|
1922
|
+
] as never);
|
|
1923
|
+
addOwners = jest
|
|
1924
|
+
.spyOn(IncidentService, "addOwners")
|
|
1925
|
+
.mockResolvedValue(undefined as never);
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
function copy(
|
|
1929
|
+
alertIds: Array<ObjectID> = [ALERT_ID, ALERT_ID_2],
|
|
1930
|
+
): Promise<{ userIds: Array<ObjectID>; teamIds: Array<ObjectID> }> {
|
|
1931
|
+
return IncidentAlertService.copyAlertOwnersToIncident({
|
|
1932
|
+
projectId: PROJECT_ID,
|
|
1933
|
+
incidentId: INCIDENT_ID,
|
|
1934
|
+
alertIds: alertIds,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
test("reads the alerts' owners as root, pinned to the project", async () => {
|
|
1939
|
+
await copy();
|
|
1940
|
+
|
|
1941
|
+
for (const [spy, column] of [
|
|
1942
|
+
[ownerUsers, "userId"],
|
|
1943
|
+
[ownerTeams, "teamId"],
|
|
1944
|
+
] as Array<[jest.SpyInstance, string]>) {
|
|
1945
|
+
const args: {
|
|
1946
|
+
query: Record<string, unknown>;
|
|
1947
|
+
select: Record<string, boolean>;
|
|
1948
|
+
props: DatabaseCommonInteractionProps;
|
|
1949
|
+
} = spy.mock.calls[0]![0];
|
|
1950
|
+
|
|
1951
|
+
expect(args.props).toEqual({ isRoot: true });
|
|
1952
|
+
expect(args.query["projectId"]).toBe(PROJECT_ID);
|
|
1953
|
+
expect(
|
|
1954
|
+
Object.values(
|
|
1955
|
+
(args.query["alertId"] as FindOperator<unknown>)
|
|
1956
|
+
.objectLiteralParameters || {},
|
|
1957
|
+
)[0],
|
|
1958
|
+
).toEqual([ALERT_ID.toString(), ALERT_ID_2.toString()]);
|
|
1959
|
+
expect(args.select).toEqual({ [column]: true });
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
test("adds each owner once, as root, without notifying them", async () => {
|
|
1964
|
+
const result: { userIds: Array<ObjectID>; teamIds: Array<ObjectID> } =
|
|
1965
|
+
await copy();
|
|
1966
|
+
|
|
1967
|
+
expect(addOwners).toHaveBeenCalledTimes(1);
|
|
1968
|
+
|
|
1969
|
+
const [projectId, incidentId, userIds, teamIds, notifyOwners, props] =
|
|
1970
|
+
addOwners.mock.calls[0]! as [
|
|
1971
|
+
ObjectID,
|
|
1972
|
+
ObjectID,
|
|
1973
|
+
Array<ObjectID>,
|
|
1974
|
+
Array<ObjectID>,
|
|
1975
|
+
boolean,
|
|
1976
|
+
DatabaseCommonInteractionProps,
|
|
1977
|
+
];
|
|
1978
|
+
|
|
1979
|
+
expect(projectId).toBe(PROJECT_ID);
|
|
1980
|
+
expect(incidentId).toBe(INCIDENT_ID);
|
|
1981
|
+
expect(userIds.map(String)).toEqual([
|
|
1982
|
+
USER_ID.toString(),
|
|
1983
|
+
OTHER_USER_ID.toString(),
|
|
1984
|
+
]);
|
|
1985
|
+
expect(teamIds.map(String)).toEqual([
|
|
1986
|
+
TEAM_ID.toString(),
|
|
1987
|
+
OTHER_TEAM_ID.toString(),
|
|
1988
|
+
]);
|
|
1989
|
+
expect(notifyOwners).toBe(false);
|
|
1990
|
+
expect(props).toEqual({ isRoot: true });
|
|
1991
|
+
expect(result.userIds.map(String)).toEqual(userIds.map(String));
|
|
1992
|
+
expect(result.teamIds.map(String)).toEqual(teamIds.map(String));
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
test("alerts without owners add nobody", async () => {
|
|
1996
|
+
ownerUsers.mockResolvedValue([] as never);
|
|
1997
|
+
ownerTeams.mockResolvedValue([] as never);
|
|
1998
|
+
|
|
1999
|
+
await expect(copy()).resolves.toEqual({ userIds: [], teamIds: [] });
|
|
2000
|
+
expect(addOwners).not.toHaveBeenCalled();
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
test("owner teams alone are still added", async () => {
|
|
2004
|
+
ownerUsers.mockResolvedValue([] as never);
|
|
2005
|
+
|
|
2006
|
+
await copy();
|
|
2007
|
+
|
|
2008
|
+
expect(addOwners.mock.calls[0]![2]).toEqual([]);
|
|
2009
|
+
expect(addOwners.mock.calls[0]![3].map(String)).toEqual([
|
|
2010
|
+
TEAM_ID.toString(),
|
|
2011
|
+
OTHER_TEAM_ID.toString(),
|
|
2012
|
+
]);
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
test("no alerts: nothing is read or added", async () => {
|
|
2016
|
+
await expect(copy([])).resolves.toEqual({ userIds: [], teamIds: [] });
|
|
2017
|
+
expect(ownerUsers).not.toHaveBeenCalled();
|
|
2018
|
+
expect(ownerTeams).not.toHaveBeenCalled();
|
|
2019
|
+
expect(addOwners).not.toHaveBeenCalled();
|
|
2020
|
+
});
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
describe("a duplicate caught by the unique index answers like the unique-together check", () => {
|
|
2024
|
+
type DriverError = Error & {
|
|
2025
|
+
code?: string;
|
|
2026
|
+
table?: string;
|
|
2027
|
+
detail?: string;
|
|
2028
|
+
driverError?: { code: string; table?: string; detail?: string };
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
const DUPLICATE_DETAIL: string = `Key ("incidentId", "alertId", "projectId")=(${INCIDENT_ID.toString()}, ${ALERT_ID.toString()}, ${PROJECT_ID.toString()}) already exists.`;
|
|
2032
|
+
|
|
2033
|
+
/*
|
|
2034
|
+
* A QueryFailedError as TypeORM raises it for Postgres: the pg fields are
|
|
2035
|
+
* hoisted onto the error and kept under driverError. `hoisted: false` keeps
|
|
2036
|
+
* them under driverError only.
|
|
2037
|
+
*/
|
|
2038
|
+
function driverError(
|
|
2039
|
+
code: string,
|
|
2040
|
+
table: string | undefined,
|
|
2041
|
+
detail: string,
|
|
2042
|
+
hoisted: boolean = true,
|
|
2043
|
+
): DriverError {
|
|
2044
|
+
const error: DriverError = new Error(
|
|
2045
|
+
"duplicate key value violates unique constraint",
|
|
2046
|
+
);
|
|
2047
|
+
error.driverError = { code: code, detail: detail };
|
|
2048
|
+
if (table) {
|
|
2049
|
+
error.driverError.table = table;
|
|
2050
|
+
}
|
|
2051
|
+
if (hoisted) {
|
|
2052
|
+
error.code = code;
|
|
2053
|
+
error.detail = detail;
|
|
2054
|
+
if (table) {
|
|
2055
|
+
error.table = table;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
return error;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// What getException throws for `error` (it always throws).
|
|
2062
|
+
function thrownFor(error: unknown): unknown {
|
|
2063
|
+
const service: { getException: (error: unknown) => never } =
|
|
2064
|
+
IncidentAlertService as unknown as {
|
|
2065
|
+
getException: (error: unknown) => never;
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
try {
|
|
2069
|
+
service.getException(error);
|
|
2070
|
+
} catch (thrown) {
|
|
2071
|
+
return thrown;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
throw new Error("getException returned instead of throwing");
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
test("without the override a duplicate insert would read as a generic 'already exists'", () => {
|
|
2078
|
+
const translated: unknown = PostgresErrorTranslator.translate(
|
|
2079
|
+
driverError("23505", "IncidentAlert", DUPLICATE_DETAIL),
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
expect((translated as Error).message).toContain("already exists");
|
|
2083
|
+
expect((translated as Error).message).not.toBe(
|
|
2084
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
2085
|
+
);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
test.each([
|
|
2089
|
+
[
|
|
2090
|
+
"names IncidentAlert",
|
|
2091
|
+
driverError("23505", "IncidentAlert", DUPLICATE_DETAIL),
|
|
2092
|
+
],
|
|
2093
|
+
[
|
|
2094
|
+
"names IncidentAlert under driverError only",
|
|
2095
|
+
driverError("23505", "IncidentAlert", DUPLICATE_DETAIL, false),
|
|
2096
|
+
],
|
|
2097
|
+
["names no table", driverError("23505", undefined, DUPLICATE_DETAIL)],
|
|
2098
|
+
[
|
|
2099
|
+
"was already translated",
|
|
2100
|
+
PostgresErrorTranslator.createUniqueViolationException(
|
|
2101
|
+
"A Incident Alert with the same Incident Id, Alert Id, Project Id already exists. Please use different values and try again.",
|
|
2102
|
+
),
|
|
2103
|
+
],
|
|
2104
|
+
])(
|
|
2105
|
+
"a unique violation that %s answers 'already linked' and stays a unique violation",
|
|
2106
|
+
(_label: string, error: unknown) => {
|
|
2107
|
+
const thrown: unknown = thrownFor(error);
|
|
2108
|
+
|
|
2109
|
+
expect(thrown).toBeInstanceOf(BadDataException);
|
|
2110
|
+
expect((thrown as Error).message).toBe(
|
|
2111
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
2112
|
+
);
|
|
2113
|
+
expect(PostgresErrorTranslator.isUniqueViolation(thrown)).toBe(true);
|
|
2114
|
+
},
|
|
2115
|
+
);
|
|
2116
|
+
|
|
2117
|
+
test("a unique violation on another table keeps the generic translation", () => {
|
|
2118
|
+
const thrown: unknown = thrownFor(
|
|
2119
|
+
driverError("23505", "AlertFeed", 'Key ("slug")=(x) already exists.'),
|
|
2120
|
+
);
|
|
2121
|
+
|
|
2122
|
+
expect((thrown as Error).message).toBe(
|
|
2123
|
+
"A Alert Feed with this Slug already exists. Please use a different value and try again.",
|
|
2124
|
+
);
|
|
2125
|
+
expect(PostgresErrorTranslator.isUniqueViolation(thrown)).toBe(true);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
test("a foreign key violation keeps the generic translation", () => {
|
|
2129
|
+
const error: DriverError = driverError(
|
|
2130
|
+
"23503",
|
|
2131
|
+
"IncidentAlert",
|
|
2132
|
+
`Key ("alertId")=(${ALERT_ID.toString()}) is not present in table "Alert".`,
|
|
2133
|
+
);
|
|
2134
|
+
|
|
2135
|
+
const thrown: unknown = thrownFor(error);
|
|
2136
|
+
|
|
2137
|
+
expect((thrown as Error).message).not.toBe(
|
|
2138
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
2139
|
+
);
|
|
2140
|
+
expect(PostgresErrorTranslator.isUniqueViolation(thrown)).toBe(false);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
test("anything that is not a Postgres error is passed on as it is", () => {
|
|
2144
|
+
const error: Error = new Error("connection reset");
|
|
2145
|
+
|
|
2146
|
+
expect(thrownFor(error)).toBe(error);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
describe("through create", () => {
|
|
2150
|
+
let save: jest.Mock;
|
|
2151
|
+
let countBy: jest.SpyInstance;
|
|
2152
|
+
|
|
2153
|
+
beforeEach(() => {
|
|
2154
|
+
save = jest.fn();
|
|
2155
|
+
jest
|
|
2156
|
+
.spyOn(IncidentAlertService, "getRepository")
|
|
2157
|
+
.mockReturnValue({ save: save } as never);
|
|
2158
|
+
countBy = jest
|
|
2159
|
+
.spyOn(IncidentAlertService, "countBy")
|
|
2160
|
+
.mockResolvedValue(new PositiveNumber(0) as never);
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
function createLink(): Promise<IncidentAlert> {
|
|
2164
|
+
return IncidentAlertService.create({
|
|
2165
|
+
data: buildLink(),
|
|
2166
|
+
props: { isRoot: true },
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
async function failureOf(run: () => Promise<unknown>): Promise<unknown> {
|
|
2171
|
+
try {
|
|
2172
|
+
await run();
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
return error;
|
|
2175
|
+
}
|
|
2176
|
+
throw new Error("expected the create to fail");
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
test("two requests racing past the unique-together check: the index's refusal reads 'already linked'", async () => {
|
|
2180
|
+
save.mockRejectedValue(
|
|
2181
|
+
driverError("23505", "IncidentAlert", DUPLICATE_DETAIL) as never,
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
const failure: unknown = await failureOf(createLink);
|
|
2185
|
+
|
|
2186
|
+
// The unique-together check ran and found nothing, as in a race.
|
|
2187
|
+
expect(countBy).toHaveBeenCalledTimes(1);
|
|
2188
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
2189
|
+
expect((failure as Error).message).toBe(
|
|
2190
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
2191
|
+
);
|
|
2192
|
+
expect(PostgresErrorTranslator.isUniqueViolation(failure)).toBe(true);
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
test("the unique-together check answers with the same message before any insert", async () => {
|
|
2196
|
+
countBy.mockResolvedValue(new PositiveNumber(1) as never);
|
|
2197
|
+
|
|
2198
|
+
const failure: unknown = await failureOf(createLink);
|
|
2199
|
+
|
|
2200
|
+
expect(save).not.toHaveBeenCalled();
|
|
2201
|
+
expect((failure as Error).message).toBe(
|
|
2202
|
+
INCIDENT_ALERT_ALREADY_LINKED_MESSAGE,
|
|
2203
|
+
);
|
|
2204
|
+
expect(PostgresErrorTranslator.isUniqueViolation(failure)).toBe(true);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
test("linkAlertsToIncident counts the index's refusal as already linked", async () => {
|
|
2208
|
+
save.mockRejectedValue(
|
|
2209
|
+
driverError("23505", "IncidentAlert", DUPLICATE_DETAIL) as never,
|
|
2210
|
+
);
|
|
2211
|
+
|
|
2212
|
+
const result: LinkAlertsToIncidentResult =
|
|
2213
|
+
await IncidentAlertService.linkAlertsToIncident({
|
|
2214
|
+
projectId: PROJECT_ID,
|
|
2215
|
+
incidentId: INCIDENT_ID,
|
|
2216
|
+
alertIds: [ALERT_ID],
|
|
2217
|
+
props: { isRoot: true },
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
expect(result.alreadyLinkedAlertIds).toEqual([ALERT_ID]);
|
|
2221
|
+
expect(result.linkedAlertIds).toEqual([]);
|
|
2222
|
+
expect(result.failed).toEqual([]);
|
|
2223
|
+
});
|
|
2224
|
+
});
|
|
2225
|
+
});
|