@oneuptime/common 14.0.5 → 14.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/MetricService.ts +1 -1
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Types/Database/QueryHelper.ts +54 -1
- package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
- package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
- package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
- package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
- package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
- package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
- package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
- package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
- package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
- package/Types/BaseDatabase/AggregationType.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +20 -7
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
- package/Types/Rum/WebVitals.ts +13 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/UI/Components/Input/Input.tsx +11 -0
- package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +12 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/UI/Components/Input/Input.js +1 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
jest,
|
|
7
|
+
test,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
import type { SpyInstance } from "jest-mock";
|
|
10
|
+
import { FindOperator } from "typeorm";
|
|
11
|
+
import Alert from "../../../../Models/DatabaseModels/Alert";
|
|
12
|
+
import AlertOwnerTeam from "../../../../Models/DatabaseModels/AlertOwnerTeam";
|
|
13
|
+
import AlertOwnerUser from "../../../../Models/DatabaseModels/AlertOwnerUser";
|
|
14
|
+
import AlertStateTimeline from "../../../../Models/DatabaseModels/AlertStateTimeline";
|
|
15
|
+
import Label from "../../../../Models/DatabaseModels/Label";
|
|
16
|
+
import AlertOwnerTeamService from "../../../../Server/Services/AlertOwnerTeamService";
|
|
17
|
+
import AlertOwnerUserService from "../../../../Server/Services/AlertOwnerUserService";
|
|
18
|
+
import AlertService from "../../../../Server/Services/AlertService";
|
|
19
|
+
import AlertStateTimelineService from "../../../../Server/Services/AlertStateTimelineService";
|
|
20
|
+
import ModelPermission from "../../../../Server/Types/Database/Permissions/Index";
|
|
21
|
+
import Query from "../../../../Server/Types/Database/Query";
|
|
22
|
+
import AlertStateChangeAuthorization from "../../../../Server/Utils/Alert/AlertStateChangeAuthorization";
|
|
23
|
+
import DatabaseCommonInteractionProps from "../../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
24
|
+
import PermissionScope from "../../../../Types/Database/AccessControl/PermissionScope";
|
|
25
|
+
import { LIMIT_PER_PROJECT } from "../../../../Types/Database/LimitMax";
|
|
26
|
+
import NotAuthenticatedException from "../../../../Types/Exception/NotAuthenticatedException";
|
|
27
|
+
import NotAuthorizedException from "../../../../Types/Exception/NotAuthorizedException";
|
|
28
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
29
|
+
import Permission, {
|
|
30
|
+
UserGlobalAccessPermission,
|
|
31
|
+
UserPermission,
|
|
32
|
+
UserTenantAccessPermission,
|
|
33
|
+
} from "../../../../Types/Permission";
|
|
34
|
+
import UserType from "../../../../Types/UserType";
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* AlertStateChangeAuthorization decides whether a caller may change the state
|
|
38
|
+
* of a set of alerts that are then acknowledged for them as root (declaring an
|
|
39
|
+
* incident with "acknowledge these alerts" ticked). These tests run the REAL
|
|
40
|
+
* permission layer (ModelPermission and everything under it) against realistic
|
|
41
|
+
* interaction props. Only the root reads the permission layer cannot make
|
|
42
|
+
* without a database are stubbed: AlertService.findBy (the helper's two reads)
|
|
43
|
+
* and, for the Owned scope, the AlertOwnerUser / AlertOwnerTeam lookups. The
|
|
44
|
+
* second AlertService.findBy stub plays the database: it returns the rows the
|
|
45
|
+
* scoped query would match, and each test pins the scope that query carries.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type PermissionInput = {
|
|
49
|
+
permission: Permission;
|
|
50
|
+
isBlockPermission?: boolean;
|
|
51
|
+
labelIds?: Array<ObjectID>;
|
|
52
|
+
scope?: PermissionScope;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type FindBySpy = SpyInstance<typeof AlertService.findBy>;
|
|
56
|
+
|
|
57
|
+
type FindByArgument = Parameters<typeof AlertService.findBy>[0];
|
|
58
|
+
|
|
59
|
+
type RawClause = {
|
|
60
|
+
sql: string;
|
|
61
|
+
params: Array<unknown>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type WriteSpies = {
|
|
65
|
+
alertCreate: SpyInstance<typeof AlertService.create>;
|
|
66
|
+
alertUpdateOneById: SpyInstance<typeof AlertService.updateOneById>;
|
|
67
|
+
alertUpdateOneBy: SpyInstance<typeof AlertService.updateOneBy>;
|
|
68
|
+
alertUpdateBy: SpyInstance<typeof AlertService.updateBy>;
|
|
69
|
+
alertChangeState: SpyInstance<typeof AlertService.changeAlertState>;
|
|
70
|
+
alertAcknowledge: SpyInstance<typeof AlertService.acknowledgeAlert>;
|
|
71
|
+
timelineCreate: SpyInstance<typeof AlertStateTimelineService.create>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const REFUSAL_MESSAGE: string =
|
|
75
|
+
"You do not have permission to change the state of one or more of these alerts.";
|
|
76
|
+
|
|
77
|
+
const projectId: ObjectID = ObjectID.generate();
|
|
78
|
+
const userId: ObjectID = ObjectID.generate();
|
|
79
|
+
|
|
80
|
+
function createDatabaseProps(
|
|
81
|
+
permissions: Array<PermissionInput>,
|
|
82
|
+
options?: {
|
|
83
|
+
userTeamIds?: Array<ObjectID>;
|
|
84
|
+
withoutUser?: boolean;
|
|
85
|
+
userType?: UserType;
|
|
86
|
+
},
|
|
87
|
+
): DatabaseCommonInteractionProps {
|
|
88
|
+
const userPermissions: Array<UserPermission> = permissions.map(
|
|
89
|
+
(permissionInput: PermissionInput): UserPermission => {
|
|
90
|
+
return {
|
|
91
|
+
_type: "UserPermission",
|
|
92
|
+
permission: permissionInput.permission,
|
|
93
|
+
isBlockPermission: permissionInput.isBlockPermission || false,
|
|
94
|
+
labelIds: permissionInput.labelIds || [],
|
|
95
|
+
scope: permissionInput.scope,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const tenantAccessPermission: UserTenantAccessPermission = {
|
|
101
|
+
_type: "UserTenantAccessPermission",
|
|
102
|
+
projectId,
|
|
103
|
+
permissions: userPermissions,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const globalAccessPermission: UserGlobalAccessPermission = {
|
|
107
|
+
_type: "UserGlobalAccessPermission",
|
|
108
|
+
globalPermissions: [
|
|
109
|
+
Permission.Public,
|
|
110
|
+
Permission.User,
|
|
111
|
+
Permission.CurrentUser,
|
|
112
|
+
],
|
|
113
|
+
projectIds: [projectId],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const props: DatabaseCommonInteractionProps = {
|
|
117
|
+
tenantId: projectId,
|
|
118
|
+
userTeamIds: options?.userTeamIds || [],
|
|
119
|
+
userGlobalAccessPermission: globalAccessPermission,
|
|
120
|
+
userTenantAccessPermission: {
|
|
121
|
+
[projectId.toString()]: tenantAccessPermission,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!options?.withoutUser) {
|
|
126
|
+
props.userId = userId;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (options?.userType) {
|
|
130
|
+
props.userType = options.userType;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return props;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createLabel(name: string): Label {
|
|
137
|
+
const label: Label = new Label(ObjectID.generate());
|
|
138
|
+
label.name = name;
|
|
139
|
+
return label;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A row as the helper's first (root) read returns it: _id plus labels.
|
|
143
|
+
function createAlertRow(alertId: ObjectID, labels: Array<Label> = []): Alert {
|
|
144
|
+
const alert: Alert = new Alert();
|
|
145
|
+
alert.id = alertId;
|
|
146
|
+
alert.labels = labels;
|
|
147
|
+
return alert;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A row as the helper's second (scoped) read returns it: _id only.
|
|
151
|
+
function createPermittedRow(alertId: ObjectID | string): Alert {
|
|
152
|
+
const alert: Alert = new Alert();
|
|
153
|
+
alert._id = alertId.toString();
|
|
154
|
+
return alert;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function asFindOperator(value: unknown): FindOperator<unknown> {
|
|
158
|
+
expect(value).toBeInstanceOf(FindOperator);
|
|
159
|
+
return value as FindOperator<unknown>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The ids (or other values) bound into a Raw FindOperator such as QueryHelper.any.
|
|
163
|
+
function getRawParameterValues(value: unknown): Array<unknown> {
|
|
164
|
+
return Object.values(asFindOperator(value).objectLiteralParameters || {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getRawClause(value: unknown): RawClause {
|
|
168
|
+
const operator: FindOperator<unknown> = asFindOperator(value);
|
|
169
|
+
const getSql: ((aliasPath: string) => string) | undefined = operator.getSql;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
sql: getSql ? getSql("COLUMN") : "",
|
|
173
|
+
params: Object.values(operator.objectLiteralParameters || {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function toIdStrings(ids: Array<ObjectID>): Array<string> {
|
|
178
|
+
return ids.map((id: ObjectID): string => {
|
|
179
|
+
return id.toString();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getFindByArgument(
|
|
184
|
+
findBySpy: FindBySpy,
|
|
185
|
+
callIndex: number,
|
|
186
|
+
): FindByArgument {
|
|
187
|
+
const argument: FindByArgument | undefined =
|
|
188
|
+
findBySpy.mock.calls[callIndex]?.[0];
|
|
189
|
+
|
|
190
|
+
if (!argument) {
|
|
191
|
+
throw new Error(`AlertService.findBy call ${callIndex} was not made`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return argument;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getQueryValue(query: Query<Alert>, key: string): unknown {
|
|
198
|
+
return (query as Record<string, unknown>)[key];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stubAlertReads(
|
|
202
|
+
firstReadRows: Array<Alert>,
|
|
203
|
+
permittedRows: Array<Alert>,
|
|
204
|
+
): FindBySpy {
|
|
205
|
+
return jest
|
|
206
|
+
.spyOn(AlertService, "findBy")
|
|
207
|
+
.mockResolvedValueOnce(firstReadRows)
|
|
208
|
+
.mockResolvedValueOnce(permittedRows);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function spyOnWrites(): WriteSpies {
|
|
212
|
+
const unexpectedWrite: Error = new Error(
|
|
213
|
+
"AlertStateChangeAuthorization must not write anything",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
alertCreate: jest
|
|
218
|
+
.spyOn(AlertService, "create")
|
|
219
|
+
.mockRejectedValue(unexpectedWrite),
|
|
220
|
+
alertUpdateOneById: jest
|
|
221
|
+
.spyOn(AlertService, "updateOneById")
|
|
222
|
+
.mockRejectedValue(unexpectedWrite),
|
|
223
|
+
alertUpdateOneBy: jest
|
|
224
|
+
.spyOn(AlertService, "updateOneBy")
|
|
225
|
+
.mockRejectedValue(unexpectedWrite),
|
|
226
|
+
alertUpdateBy: jest
|
|
227
|
+
.spyOn(AlertService, "updateBy")
|
|
228
|
+
.mockRejectedValue(unexpectedWrite),
|
|
229
|
+
alertChangeState: jest
|
|
230
|
+
.spyOn(AlertService, "changeAlertState")
|
|
231
|
+
.mockRejectedValue(unexpectedWrite),
|
|
232
|
+
alertAcknowledge: jest
|
|
233
|
+
.spyOn(AlertService, "acknowledgeAlert")
|
|
234
|
+
.mockRejectedValue(unexpectedWrite),
|
|
235
|
+
timelineCreate: jest
|
|
236
|
+
.spyOn(AlertStateTimelineService, "create")
|
|
237
|
+
.mockRejectedValue(unexpectedWrite),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function expectNoWrites(writeSpies: WriteSpies): void {
|
|
242
|
+
expect(writeSpies.alertCreate).not.toHaveBeenCalled();
|
|
243
|
+
expect(writeSpies.alertUpdateOneById).not.toHaveBeenCalled();
|
|
244
|
+
expect(writeSpies.alertUpdateOneBy).not.toHaveBeenCalled();
|
|
245
|
+
expect(writeSpies.alertUpdateBy).not.toHaveBeenCalled();
|
|
246
|
+
expect(writeSpies.alertChangeState).not.toHaveBeenCalled();
|
|
247
|
+
expect(writeSpies.alertAcknowledge).not.toHaveBeenCalled();
|
|
248
|
+
expect(writeSpies.timelineCreate).not.toHaveBeenCalled();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
describe("AlertStateChangeAuthorization.assertCanChangeStateOfAlerts", (): void => {
|
|
252
|
+
let writeSpies: WriteSpies;
|
|
253
|
+
let createPermissionSpy: SpyInstance<
|
|
254
|
+
typeof ModelPermission.checkCreatePermissions
|
|
255
|
+
>;
|
|
256
|
+
let updateByModelSpy: SpyInstance<
|
|
257
|
+
typeof ModelPermission.checkUpdatePermissionByModel
|
|
258
|
+
>;
|
|
259
|
+
let updateQuerySpy: SpyInstance<
|
|
260
|
+
typeof ModelPermission.checkUpdateQueryPermissions
|
|
261
|
+
>;
|
|
262
|
+
let findOneBySpy: SpyInstance<typeof AlertService.findOneBy>;
|
|
263
|
+
|
|
264
|
+
beforeEach((): void => {
|
|
265
|
+
writeSpies = spyOnWrites();
|
|
266
|
+
// Pass-through spies: the real permission layer still runs.
|
|
267
|
+
createPermissionSpy = jest.spyOn(ModelPermission, "checkCreatePermissions");
|
|
268
|
+
updateByModelSpy = jest.spyOn(
|
|
269
|
+
ModelPermission,
|
|
270
|
+
"checkUpdatePermissionByModel",
|
|
271
|
+
);
|
|
272
|
+
updateQuerySpy = jest.spyOn(ModelPermission, "checkUpdateQueryPermissions");
|
|
273
|
+
findOneBySpy = jest
|
|
274
|
+
.spyOn(AlertService, "findOneBy")
|
|
275
|
+
.mockRejectedValue(new Error("the helper reads alerts with findBy only"));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
afterEach((): void => {
|
|
279
|
+
jest.restoreAllMocks();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("short circuits", (): void => {
|
|
283
|
+
test("a root caller is let through without a permission check or any read", async (): Promise<void> => {
|
|
284
|
+
const findBySpy: FindBySpy = jest
|
|
285
|
+
.spyOn(AlertService, "findBy")
|
|
286
|
+
.mockRejectedValue(new Error("root must not read alerts"));
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
290
|
+
projectId,
|
|
291
|
+
alertIds: [ObjectID.generate(), ObjectID.generate()],
|
|
292
|
+
props: { isRoot: true },
|
|
293
|
+
}),
|
|
294
|
+
).resolves.toBeUndefined();
|
|
295
|
+
|
|
296
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
297
|
+
expect(findOneBySpy).not.toHaveBeenCalled();
|
|
298
|
+
expect(createPermissionSpy).not.toHaveBeenCalled();
|
|
299
|
+
expect(updateByModelSpy).not.toHaveBeenCalled();
|
|
300
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
301
|
+
expectNoWrites(writeSpies);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("an empty alert list is let through without a permission check or any read, whoever the caller is", async (): Promise<void> => {
|
|
305
|
+
const findBySpy: FindBySpy = jest
|
|
306
|
+
.spyOn(AlertService, "findBy")
|
|
307
|
+
.mockRejectedValue(new Error("no alerts means no read"));
|
|
308
|
+
|
|
309
|
+
// A Viewer could not change any alert's state; with no alerts it does not matter.
|
|
310
|
+
await expect(
|
|
311
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
312
|
+
projectId,
|
|
313
|
+
alertIds: [],
|
|
314
|
+
props: createDatabaseProps([{ permission: Permission.Viewer }]),
|
|
315
|
+
}),
|
|
316
|
+
).resolves.toBeUndefined();
|
|
317
|
+
|
|
318
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
319
|
+
expect(createPermissionSpy).not.toHaveBeenCalled();
|
|
320
|
+
expect(updateByModelSpy).not.toHaveBeenCalled();
|
|
321
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
322
|
+
expectNoWrites(writeSpies);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("the AlertStateTimeline create half", (): void => {
|
|
327
|
+
test.each([
|
|
328
|
+
{ name: "Viewer", permissions: [Permission.Viewer] },
|
|
329
|
+
{ name: "AlertViewer", permissions: [Permission.AlertViewer] },
|
|
330
|
+
{
|
|
331
|
+
name: "ReadAlert with ReadAlertStateTimeline",
|
|
332
|
+
permissions: [Permission.ReadAlert, Permission.ReadAlertStateTimeline],
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "EditAlert without CreateAlertStateTimeline",
|
|
336
|
+
permissions: [Permission.EditAlert, Permission.ReadAlert],
|
|
337
|
+
},
|
|
338
|
+
])(
|
|
339
|
+
"$name is refused before any alert is read",
|
|
340
|
+
async ({
|
|
341
|
+
permissions,
|
|
342
|
+
}: {
|
|
343
|
+
name: string;
|
|
344
|
+
permissions: Array<Permission>;
|
|
345
|
+
}): Promise<void> => {
|
|
346
|
+
const findBySpy: FindBySpy = jest
|
|
347
|
+
.spyOn(AlertService, "findBy")
|
|
348
|
+
.mockRejectedValue(
|
|
349
|
+
new Error("must not read before the create check"),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const promise: Promise<void> =
|
|
353
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
354
|
+
projectId,
|
|
355
|
+
alertIds: [ObjectID.generate()],
|
|
356
|
+
props: createDatabaseProps(
|
|
357
|
+
permissions.map((permission: Permission): PermissionInput => {
|
|
358
|
+
return { permission };
|
|
359
|
+
}),
|
|
360
|
+
),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
364
|
+
await expect(promise).rejects.toThrow(
|
|
365
|
+
"You do not have permissions to create Alert State Timeline.",
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(createPermissionSpy).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
370
|
+
expect(updateByModelSpy).not.toHaveBeenCalled();
|
|
371
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
372
|
+
expectNoWrites(writeSpies);
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
test("a table-wide block on CreateAlertStateTimeline wins over ProjectMember, before any alert is read", async (): Promise<void> => {
|
|
377
|
+
const findBySpy: FindBySpy = jest
|
|
378
|
+
.spyOn(AlertService, "findBy")
|
|
379
|
+
.mockRejectedValue(new Error("must not read before the create check"));
|
|
380
|
+
|
|
381
|
+
const promise: Promise<void> =
|
|
382
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
383
|
+
projectId,
|
|
384
|
+
alertIds: [ObjectID.generate()],
|
|
385
|
+
props: createDatabaseProps([
|
|
386
|
+
{ permission: Permission.ProjectMember },
|
|
387
|
+
{
|
|
388
|
+
permission: Permission.CreateAlertStateTimeline,
|
|
389
|
+
isBlockPermission: true,
|
|
390
|
+
},
|
|
391
|
+
]),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
395
|
+
await expect(promise).rejects.toThrow(
|
|
396
|
+
"because CreateAlertStateTimeline is in your team's permission block list.",
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
400
|
+
expectNoWrites(writeSpies);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("a caller with no credentials keeps the 401 (NotAuthenticatedException) and nothing is read", async (): Promise<void> => {
|
|
404
|
+
const findBySpy: FindBySpy = jest
|
|
405
|
+
.spyOn(AlertService, "findBy")
|
|
406
|
+
.mockRejectedValue(new Error("must not read for an anonymous caller"));
|
|
407
|
+
|
|
408
|
+
await expect(
|
|
409
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
410
|
+
projectId,
|
|
411
|
+
alertIds: [ObjectID.generate()],
|
|
412
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }], {
|
|
413
|
+
withoutUser: true,
|
|
414
|
+
}),
|
|
415
|
+
}),
|
|
416
|
+
).rejects.toBeInstanceOf(NotAuthenticatedException);
|
|
417
|
+
|
|
418
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
419
|
+
expectNoWrites(writeSpies);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("the probe is an AlertStateTimeline in the project, for the first alert, with an alert state id", async (): Promise<void> => {
|
|
423
|
+
const firstAlertId: ObjectID = ObjectID.generate();
|
|
424
|
+
const secondAlertId: ObjectID = ObjectID.generate();
|
|
425
|
+
const props: DatabaseCommonInteractionProps = createDatabaseProps([
|
|
426
|
+
{ permission: Permission.AlertMember },
|
|
427
|
+
]);
|
|
428
|
+
stubAlertReads(
|
|
429
|
+
[createAlertRow(firstAlertId), createAlertRow(secondAlertId)],
|
|
430
|
+
[createPermittedRow(firstAlertId), createPermittedRow(secondAlertId)],
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
434
|
+
projectId,
|
|
435
|
+
alertIds: [firstAlertId, secondAlertId],
|
|
436
|
+
props,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(createPermissionSpy).toHaveBeenCalledTimes(1);
|
|
440
|
+
const [modelType, probe, probeProps] = createPermissionSpy.mock
|
|
441
|
+
.calls[0] as unknown as [
|
|
442
|
+
typeof AlertStateTimeline,
|
|
443
|
+
AlertStateTimeline,
|
|
444
|
+
DatabaseCommonInteractionProps,
|
|
445
|
+
];
|
|
446
|
+
expect(modelType).toBe(AlertStateTimeline);
|
|
447
|
+
expect(probe).toBeInstanceOf(AlertStateTimeline);
|
|
448
|
+
expect(probe.projectId?.toString()).toBe(projectId.toString());
|
|
449
|
+
expect(probe.alertId?.toString()).toBe(firstAlertId.toString());
|
|
450
|
+
expect(probe.alertStateId).toBeInstanceOf(ObjectID);
|
|
451
|
+
expect(probe.alertStateId?.toString()).toMatch(
|
|
452
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
453
|
+
);
|
|
454
|
+
expect(probeProps).toBe(props);
|
|
455
|
+
expectNoWrites(writeSpies);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("the Alert update half", (): void => {
|
|
460
|
+
test("an AlertMember with project-wide access may change every alert the scoped read returns", async (): Promise<void> => {
|
|
461
|
+
const alertIds: Array<ObjectID> = [
|
|
462
|
+
ObjectID.generate(),
|
|
463
|
+
ObjectID.generate(),
|
|
464
|
+
ObjectID.generate(),
|
|
465
|
+
];
|
|
466
|
+
const props: DatabaseCommonInteractionProps = createDatabaseProps([
|
|
467
|
+
{ permission: Permission.AlertMember },
|
|
468
|
+
]);
|
|
469
|
+
const firstReadRows: Array<Alert> = alertIds.map(
|
|
470
|
+
(alertId: ObjectID): Alert => {
|
|
471
|
+
return createAlertRow(alertId);
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
// The database may return the permitted rows in any order.
|
|
475
|
+
const findBySpy: FindBySpy = stubAlertReads(firstReadRows, [
|
|
476
|
+
createPermittedRow(alertIds[2]!),
|
|
477
|
+
createPermittedRow(alertIds[0]!),
|
|
478
|
+
createPermittedRow(alertIds[1]!),
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
await expect(
|
|
482
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
483
|
+
projectId,
|
|
484
|
+
alertIds,
|
|
485
|
+
props,
|
|
486
|
+
}),
|
|
487
|
+
).resolves.toBeUndefined();
|
|
488
|
+
|
|
489
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
490
|
+
|
|
491
|
+
// Read 1: the alerts and their labels, as root, bounded per project.
|
|
492
|
+
const firstRead: FindByArgument = getFindByArgument(findBySpy, 0);
|
|
493
|
+
expect(firstRead.select).toEqual({
|
|
494
|
+
_id: true,
|
|
495
|
+
labels: {
|
|
496
|
+
_id: true,
|
|
497
|
+
name: true,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
expect(firstRead.limit).toBe(LIMIT_PER_PROJECT);
|
|
501
|
+
expect(firstRead.skip).toBe(0);
|
|
502
|
+
expect(firstRead.props).toEqual({ isRoot: true });
|
|
503
|
+
expect(Object.keys(firstRead.query).sort()).toEqual(["_id", "projectId"]);
|
|
504
|
+
expect(getQueryValue(firstRead.query, "projectId")).toBe(projectId);
|
|
505
|
+
expect(
|
|
506
|
+
getRawParameterValues(getQueryValue(firstRead.query, "_id")),
|
|
507
|
+
).toEqual([toIdStrings(alertIds)]);
|
|
508
|
+
|
|
509
|
+
// Every row read is checked against the caller's update rules.
|
|
510
|
+
expect(updateByModelSpy).toHaveBeenCalledTimes(3);
|
|
511
|
+
for (let index: number = 0; index < firstReadRows.length; index++) {
|
|
512
|
+
const call: Parameters<
|
|
513
|
+
typeof ModelPermission.checkUpdatePermissionByModel
|
|
514
|
+
>[0] = updateByModelSpy.mock.calls[index]![0] as Parameters<
|
|
515
|
+
typeof ModelPermission.checkUpdatePermissionByModel
|
|
516
|
+
>[0];
|
|
517
|
+
expect(call.modelType).toBe(Alert);
|
|
518
|
+
expect(call.props).toBe(props);
|
|
519
|
+
await expect(call.fetchModelWithAccessControlIds()).resolves.toBe(
|
|
520
|
+
firstReadRows[index],
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Read 2: the caller's update scope, as root, bounded per project.
|
|
525
|
+
const permittedRead: FindByArgument = getFindByArgument(findBySpy, 1);
|
|
526
|
+
expect(permittedRead.select).toEqual({ _id: true });
|
|
527
|
+
expect(permittedRead.limit).toBe(LIMIT_PER_PROJECT);
|
|
528
|
+
expect(permittedRead.skip).toBe(0);
|
|
529
|
+
expect(permittedRead.props).toEqual({ isRoot: true });
|
|
530
|
+
expect(
|
|
531
|
+
getRawParameterValues(getQueryValue(permittedRead.query, "_id")),
|
|
532
|
+
).toEqual([toIdStrings(alertIds)]);
|
|
533
|
+
expect(
|
|
534
|
+
getRawParameterValues(getQueryValue(permittedRead.query, "projectId")),
|
|
535
|
+
).toEqual([projectId.toString()]);
|
|
536
|
+
// Project-wide access adds no label scope.
|
|
537
|
+
expect(getQueryValue(permittedRead.query, "labels")).toBeUndefined();
|
|
538
|
+
|
|
539
|
+
/*
|
|
540
|
+
* Order: the create probe, read 1, the per-alert checks, the scoped
|
|
541
|
+
* update query, then read 2.
|
|
542
|
+
*/
|
|
543
|
+
const probeOrder: number =
|
|
544
|
+
createPermissionSpy.mock.invocationCallOrder[0]!;
|
|
545
|
+
const firstReadOrder: number = findBySpy.mock.invocationCallOrder[0]!;
|
|
546
|
+
const perAlertOrders: Array<number> =
|
|
547
|
+
updateByModelSpy.mock.invocationCallOrder;
|
|
548
|
+
const updateQueryOrder: number =
|
|
549
|
+
updateQuerySpy.mock.invocationCallOrder[0]!;
|
|
550
|
+
const permittedReadOrder: number = findBySpy.mock.invocationCallOrder[1]!;
|
|
551
|
+
expect(probeOrder).toBeLessThan(firstReadOrder);
|
|
552
|
+
expect(Math.min(...perAlertOrders)).toBeGreaterThan(firstReadOrder);
|
|
553
|
+
expect(Math.max(...perAlertOrders)).toBeLessThan(updateQueryOrder);
|
|
554
|
+
expect(updateQueryOrder).toBeLessThan(permittedReadOrder);
|
|
555
|
+
|
|
556
|
+
expect(findOneBySpy).not.toHaveBeenCalled();
|
|
557
|
+
expectNoWrites(writeSpies);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("the update check is asked about {_id: any(ids), projectId} writing a zero currentAlertStateId", async (): Promise<void> => {
|
|
561
|
+
const alertIds: Array<ObjectID> = [
|
|
562
|
+
ObjectID.generate(),
|
|
563
|
+
ObjectID.generate(),
|
|
564
|
+
];
|
|
565
|
+
const props: DatabaseCommonInteractionProps = createDatabaseProps([
|
|
566
|
+
{ permission: Permission.AlertMember },
|
|
567
|
+
]);
|
|
568
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
569
|
+
alertIds.map((alertId: ObjectID): Alert => {
|
|
570
|
+
return createAlertRow(alertId);
|
|
571
|
+
}),
|
|
572
|
+
alertIds.map((alertId: ObjectID): Alert => {
|
|
573
|
+
return createPermittedRow(alertId);
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
578
|
+
projectId,
|
|
579
|
+
alertIds,
|
|
580
|
+
props,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
expect(updateQuerySpy).toHaveBeenCalledTimes(1);
|
|
584
|
+
const [modelType, query, data, queryProps] = updateQuerySpy.mock
|
|
585
|
+
.calls[0] as unknown as [
|
|
586
|
+
typeof Alert,
|
|
587
|
+
Query<Alert>,
|
|
588
|
+
Record<string, unknown>,
|
|
589
|
+
DatabaseCommonInteractionProps,
|
|
590
|
+
];
|
|
591
|
+
expect(modelType).toBe(Alert);
|
|
592
|
+
expect(queryProps).toBe(props);
|
|
593
|
+
expect(data).toEqual({ currentAlertStateId: ObjectID.getZeroObjectID() });
|
|
594
|
+
expect(Object.keys(data)).toEqual(["currentAlertStateId"]);
|
|
595
|
+
expect(Object.keys(query).sort()).toEqual(["_id", "projectId"]);
|
|
596
|
+
expect(getQueryValue(query, "projectId")).toBe(projectId);
|
|
597
|
+
expect(getRawParameterValues(getQueryValue(query, "_id"))).toEqual([
|
|
598
|
+
toIdStrings(alertIds),
|
|
599
|
+
]);
|
|
600
|
+
// The same {_id, projectId} query the rows were read with.
|
|
601
|
+
expect(query).toBe(getFindByArgument(findBySpy, 0).query);
|
|
602
|
+
// The permission layer works on a copy: the caller's query is not scoped in place.
|
|
603
|
+
expect(getQueryValue(query, "isPrivate")).toBeUndefined();
|
|
604
|
+
expectNoWrites(writeSpies);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("CreateAlertStateTimeline without any Alert update permission is refused after the first read", async (): Promise<void> => {
|
|
608
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
609
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
610
|
+
[createAlertRow(alertId)],
|
|
611
|
+
[createPermittedRow(alertId)],
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const promise: Promise<void> =
|
|
615
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
616
|
+
projectId,
|
|
617
|
+
alertIds: [alertId],
|
|
618
|
+
props: createDatabaseProps([
|
|
619
|
+
{ permission: Permission.CreateAlertStateTimeline },
|
|
620
|
+
{ permission: Permission.ReadAlert },
|
|
621
|
+
]),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
625
|
+
await expect(promise).rejects.toThrow(
|
|
626
|
+
"You do not have permissions to update Alert.",
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
expect(createPermissionSpy).toHaveBeenCalledTimes(1);
|
|
630
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
631
|
+
// Refused by the per-alert update check, before the scoped query.
|
|
632
|
+
expect(updateByModelSpy).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
634
|
+
expectNoWrites(writeSpies);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("CreateAlertStateTimeline without Alert update is still refused when the first read finds none of the alerts", async (): Promise<void> => {
|
|
638
|
+
// No rows means no per-alert check; the scoped update query must refuse by itself.
|
|
639
|
+
const findBySpy: FindBySpy = stubAlertReads([], []);
|
|
640
|
+
|
|
641
|
+
const promise: Promise<void> =
|
|
642
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
643
|
+
projectId,
|
|
644
|
+
alertIds: [ObjectID.generate()],
|
|
645
|
+
props: createDatabaseProps([
|
|
646
|
+
{ permission: Permission.CreateAlertStateTimeline },
|
|
647
|
+
{ permission: Permission.ReadAlert },
|
|
648
|
+
]),
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
652
|
+
await expect(promise).rejects.toThrow(
|
|
653
|
+
"You do not have permissions to update Alert.",
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(updateByModelSpy).not.toHaveBeenCalled();
|
|
657
|
+
expect(updateQuerySpy).toHaveBeenCalledTimes(1);
|
|
658
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
659
|
+
expectNoWrites(writeSpies);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("a custom role with CreateAlertStateTimeline and EditAlert may change the alerts", async (): Promise<void> => {
|
|
663
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
664
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
665
|
+
[createAlertRow(alertId)],
|
|
666
|
+
[createPermittedRow(alertId)],
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
await expect(
|
|
670
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
671
|
+
projectId,
|
|
672
|
+
alertIds: [alertId],
|
|
673
|
+
props: createDatabaseProps([
|
|
674
|
+
{ permission: Permission.CreateAlertStateTimeline },
|
|
675
|
+
{ permission: Permission.EditAlert },
|
|
676
|
+
{ permission: Permission.ReadAlert },
|
|
677
|
+
]),
|
|
678
|
+
}),
|
|
679
|
+
).resolves.toBeUndefined();
|
|
680
|
+
|
|
681
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
682
|
+
expectNoWrites(writeSpies);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("an alert id the project does not have (neither read returns it) is refused", async (): Promise<void> => {
|
|
686
|
+
const ownAlertId: ObjectID = ObjectID.generate();
|
|
687
|
+
const foreignAlertId: ObjectID = ObjectID.generate();
|
|
688
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
689
|
+
[createAlertRow(ownAlertId)],
|
|
690
|
+
[createPermittedRow(ownAlertId)],
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const promise: Promise<void> =
|
|
694
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
695
|
+
projectId,
|
|
696
|
+
alertIds: [ownAlertId, foreignAlertId],
|
|
697
|
+
props: createDatabaseProps([{ permission: Permission.ProjectOwner }]),
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
701
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
702
|
+
|
|
703
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
704
|
+
expect(updateByModelSpy).toHaveBeenCalledTimes(1);
|
|
705
|
+
expectNoWrites(writeSpies);
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe("label scope", (): void => {
|
|
710
|
+
test("Viewer plus AlertMember for label A is refused by the permission layer when one alert carries only label B", async (): Promise<void> => {
|
|
711
|
+
const labelA: Label = createLabel("team-a");
|
|
712
|
+
const labelB: Label = createLabel("team-b");
|
|
713
|
+
const alertA: ObjectID = ObjectID.generate();
|
|
714
|
+
const alertB: ObjectID = ObjectID.generate();
|
|
715
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
716
|
+
[createAlertRow(alertA, [labelA]), createAlertRow(alertB, [labelB])],
|
|
717
|
+
[createPermittedRow(alertA)],
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const promise: Promise<void> =
|
|
721
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
722
|
+
projectId,
|
|
723
|
+
alertIds: [alertA, alertB],
|
|
724
|
+
props: createDatabaseProps([
|
|
725
|
+
{ permission: Permission.Viewer },
|
|
726
|
+
{ permission: Permission.AlertMember, labelIds: [labelA.id!] },
|
|
727
|
+
]),
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
731
|
+
await expect(promise).rejects.toThrow(
|
|
732
|
+
"You do not have permission to update this Alert. You need to have one of the following labels: team-b.",
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// alert A passed, alert B was refused, and the scoped read never ran.
|
|
736
|
+
expect(updateByModelSpy).toHaveBeenCalledTimes(2);
|
|
737
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
738
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
739
|
+
expectNoWrites(writeSpies);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("an AlertMember for label A is refused an unlabelled alert", async (): Promise<void> => {
|
|
743
|
+
const labelA: Label = createLabel("team-a");
|
|
744
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
745
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
746
|
+
[createAlertRow(alertId, [])],
|
|
747
|
+
[],
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const promise: Promise<void> =
|
|
751
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
752
|
+
projectId,
|
|
753
|
+
alertIds: [alertId],
|
|
754
|
+
props: createDatabaseProps([
|
|
755
|
+
{ permission: Permission.AlertMember, labelIds: [labelA.id!] },
|
|
756
|
+
]),
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
760
|
+
await expect(promise).rejects.toThrow(
|
|
761
|
+
"You do not have permission to update Alert without any labels.",
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
765
|
+
expectNoWrites(writeSpies);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("Viewer plus AlertMember for label A may change alerts that all carry label A, and the scoped read is narrowed to label A", async (): Promise<void> => {
|
|
769
|
+
const labelA: Label = createLabel("team-a");
|
|
770
|
+
const labelB: Label = createLabel("team-b");
|
|
771
|
+
const firstAlert: ObjectID = ObjectID.generate();
|
|
772
|
+
const secondAlert: ObjectID = ObjectID.generate();
|
|
773
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
774
|
+
[
|
|
775
|
+
createAlertRow(firstAlert, [labelA]),
|
|
776
|
+
// Carrying another label as well does not matter.
|
|
777
|
+
createAlertRow(secondAlert, [labelB, labelA]),
|
|
778
|
+
],
|
|
779
|
+
[createPermittedRow(firstAlert), createPermittedRow(secondAlert)],
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
await expect(
|
|
783
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
784
|
+
projectId,
|
|
785
|
+
alertIds: [firstAlert, secondAlert],
|
|
786
|
+
props: createDatabaseProps([
|
|
787
|
+
{ permission: Permission.Viewer },
|
|
788
|
+
{ permission: Permission.AlertMember, labelIds: [labelA.id!] },
|
|
789
|
+
]),
|
|
790
|
+
}),
|
|
791
|
+
).resolves.toBeUndefined();
|
|
792
|
+
|
|
793
|
+
const permittedQuery: Query<Alert> = getFindByArgument(
|
|
794
|
+
findBySpy,
|
|
795
|
+
1,
|
|
796
|
+
).query;
|
|
797
|
+
const labelFilter: unknown = getQueryValue(permittedQuery, "labels");
|
|
798
|
+
expect(labelFilter).toEqual({ _id: expect.any(FindOperator) });
|
|
799
|
+
expect(
|
|
800
|
+
getRawParameterValues((labelFilter as Record<string, unknown>)["_id"]),
|
|
801
|
+
).toEqual([[labelA.id!.toString()]]);
|
|
802
|
+
expect(
|
|
803
|
+
getRawParameterValues(getQueryValue(permittedQuery, "_id")),
|
|
804
|
+
).toEqual([toIdStrings([firstAlert, secondAlert])]);
|
|
805
|
+
expectNoWrites(writeSpies);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("an alert that passes the per-alert label check but is missing from the label-scoped read is refused", async (): Promise<void> => {
|
|
809
|
+
// e.g. relabelled between the two reads: the scoped read is the final word.
|
|
810
|
+
const labelA: Label = createLabel("team-a");
|
|
811
|
+
const firstAlert: ObjectID = ObjectID.generate();
|
|
812
|
+
const secondAlert: ObjectID = ObjectID.generate();
|
|
813
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
814
|
+
[
|
|
815
|
+
createAlertRow(firstAlert, [labelA]),
|
|
816
|
+
createAlertRow(secondAlert, [labelA]),
|
|
817
|
+
],
|
|
818
|
+
[createPermittedRow(firstAlert)],
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
const promise: Promise<void> =
|
|
822
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
823
|
+
projectId,
|
|
824
|
+
alertIds: [firstAlert, secondAlert],
|
|
825
|
+
props: createDatabaseProps([
|
|
826
|
+
{ permission: Permission.AlertMember, labelIds: [labelA.id!] },
|
|
827
|
+
]),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
831
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
832
|
+
|
|
833
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
834
|
+
expectNoWrites(writeSpies);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("an EditAlert block for label B refuses an alert labelled B even for a ProjectMember", async (): Promise<void> => {
|
|
838
|
+
const labelB: Label = createLabel("team-b");
|
|
839
|
+
const alertA: ObjectID = ObjectID.generate();
|
|
840
|
+
const alertB: ObjectID = ObjectID.generate();
|
|
841
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
842
|
+
[createAlertRow(alertA, []), createAlertRow(alertB, [labelB])],
|
|
843
|
+
[createPermittedRow(alertA), createPermittedRow(alertB)],
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const promise: Promise<void> =
|
|
847
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
848
|
+
projectId,
|
|
849
|
+
alertIds: [alertA, alertB],
|
|
850
|
+
props: createDatabaseProps([
|
|
851
|
+
{ permission: Permission.ProjectMember },
|
|
852
|
+
{
|
|
853
|
+
permission: Permission.EditAlert,
|
|
854
|
+
isBlockPermission: true,
|
|
855
|
+
labelIds: [labelB.id!],
|
|
856
|
+
},
|
|
857
|
+
]),
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
861
|
+
await expect(promise).rejects.toThrow(
|
|
862
|
+
"You are not authorized to update this Alert because EditAlert is in your team's permission block list.",
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
866
|
+
expect(updateQuerySpy).not.toHaveBeenCalled();
|
|
867
|
+
expectNoWrites(writeSpies);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
describe("Owned scope", (): void => {
|
|
872
|
+
test("AlertViewer plus Owned AlertMember is refused when the scoped read leaves out an alert they do not own", async (): Promise<void> => {
|
|
873
|
+
const teamId: ObjectID = ObjectID.generate();
|
|
874
|
+
const ownedAlert: ObjectID = ObjectID.generate();
|
|
875
|
+
const otherAlert: ObjectID = ObjectID.generate();
|
|
876
|
+
const ownerUser: AlertOwnerUser = new AlertOwnerUser();
|
|
877
|
+
ownerUser.alertId = ownedAlert;
|
|
878
|
+
const ownerUserSpy: SpyInstance<typeof AlertOwnerUserService.findBy> =
|
|
879
|
+
jest
|
|
880
|
+
.spyOn(AlertOwnerUserService, "findBy")
|
|
881
|
+
.mockResolvedValue([ownerUser]);
|
|
882
|
+
const ownerTeamSpy: SpyInstance<typeof AlertOwnerTeamService.findBy> =
|
|
883
|
+
jest.spyOn(AlertOwnerTeamService, "findBy").mockResolvedValue([]);
|
|
884
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
885
|
+
[createAlertRow(ownedAlert), createAlertRow(otherAlert)],
|
|
886
|
+
[createPermittedRow(ownedAlert)],
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
const promise: Promise<void> =
|
|
890
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
891
|
+
projectId,
|
|
892
|
+
alertIds: [ownedAlert, otherAlert],
|
|
893
|
+
props: createDatabaseProps(
|
|
894
|
+
[
|
|
895
|
+
{ permission: Permission.AlertViewer },
|
|
896
|
+
{
|
|
897
|
+
permission: Permission.AlertMember,
|
|
898
|
+
scope: PermissionScope.Owned,
|
|
899
|
+
},
|
|
900
|
+
],
|
|
901
|
+
{ userTeamIds: [teamId] },
|
|
902
|
+
),
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// The per-alert check cannot see ownership; the scoped read is what refuses.
|
|
906
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
907
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
908
|
+
|
|
909
|
+
expect(updateByModelSpy).toHaveBeenCalledTimes(2);
|
|
910
|
+
expect(ownerUserSpy).toHaveBeenCalledTimes(1);
|
|
911
|
+
expect(ownerUserSpy.mock.calls[0]?.[0].query).toEqual({
|
|
912
|
+
userId,
|
|
913
|
+
projectId,
|
|
914
|
+
});
|
|
915
|
+
expect(ownerTeamSpy).toHaveBeenCalledTimes(1);
|
|
916
|
+
expect(
|
|
917
|
+
getRawParameterValues(ownerTeamSpy.mock.calls[0]?.[0].query.teamId),
|
|
918
|
+
).toEqual([[teamId.toString()]]);
|
|
919
|
+
|
|
920
|
+
// The scoped read is narrowed to {requested ids} AND {owned ids}.
|
|
921
|
+
const idFilter: FindOperator<unknown> = asFindOperator(
|
|
922
|
+
getQueryValue(getFindByArgument(findBySpy, 1).query, "_id"),
|
|
923
|
+
);
|
|
924
|
+
expect(idFilter.type).toBe("and");
|
|
925
|
+
const conditions: Array<unknown> = idFilter.value as Array<unknown>;
|
|
926
|
+
expect(conditions).toHaveLength(2);
|
|
927
|
+
expect(getRawParameterValues(conditions[0])).toEqual([
|
|
928
|
+
toIdStrings([ownedAlert, otherAlert]),
|
|
929
|
+
]);
|
|
930
|
+
expect(getRawParameterValues(conditions[1])).toEqual([
|
|
931
|
+
[ownedAlert.toString()],
|
|
932
|
+
]);
|
|
933
|
+
expectNoWrites(writeSpies);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("an Owned AlertMember may change alerts their team owns", async (): Promise<void> => {
|
|
937
|
+
const teamId: ObjectID = ObjectID.generate();
|
|
938
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
939
|
+
const ownerTeam: AlertOwnerTeam = new AlertOwnerTeam();
|
|
940
|
+
ownerTeam.alertId = alertId;
|
|
941
|
+
ownerTeam.teamId = teamId;
|
|
942
|
+
jest.spyOn(AlertOwnerUserService, "findBy").mockResolvedValue([]);
|
|
943
|
+
jest
|
|
944
|
+
.spyOn(AlertOwnerTeamService, "findBy")
|
|
945
|
+
.mockResolvedValue([ownerTeam]);
|
|
946
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
947
|
+
[createAlertRow(alertId)],
|
|
948
|
+
[createPermittedRow(alertId)],
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
await expect(
|
|
952
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
953
|
+
projectId,
|
|
954
|
+
alertIds: [alertId],
|
|
955
|
+
props: createDatabaseProps(
|
|
956
|
+
[
|
|
957
|
+
{
|
|
958
|
+
permission: Permission.AlertMember,
|
|
959
|
+
scope: PermissionScope.Owned,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
{ userTeamIds: [teamId] },
|
|
963
|
+
),
|
|
964
|
+
}),
|
|
965
|
+
).resolves.toBeUndefined();
|
|
966
|
+
|
|
967
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
968
|
+
expectNoWrites(writeSpies);
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
describe("alert privacy", (): void => {
|
|
973
|
+
test("a private alert the caller does not own is refused: the scoped read carries the self-privacy filter", async (): Promise<void> => {
|
|
974
|
+
const publicAlert: ObjectID = ObjectID.generate();
|
|
975
|
+
const privateAlert: ObjectID = ObjectID.generate();
|
|
976
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
977
|
+
[createAlertRow(publicAlert), createAlertRow(privateAlert)],
|
|
978
|
+
// What the database returns once the privacy clause hides the private alert.
|
|
979
|
+
[createPermittedRow(publicAlert)],
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
const promise: Promise<void> =
|
|
983
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
984
|
+
projectId,
|
|
985
|
+
alertIds: [publicAlert, privateAlert],
|
|
986
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }]),
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
990
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
991
|
+
|
|
992
|
+
// The first read is the unscoped root read: no privacy clause there.
|
|
993
|
+
expect(
|
|
994
|
+
getQueryValue(getFindByArgument(findBySpy, 0).query, "isPrivate"),
|
|
995
|
+
).toBeUndefined();
|
|
996
|
+
|
|
997
|
+
const privacy: RawClause = getRawClause(
|
|
998
|
+
getQueryValue(getFindByArgument(findBySpy, 1).query, "isPrivate"),
|
|
999
|
+
);
|
|
1000
|
+
expect(privacy.sql).toContain("COLUMN IS NULL OR COLUMN = FALSE");
|
|
1001
|
+
expect(privacy.sql).toContain('FROM "AlertOwnerUser"');
|
|
1002
|
+
expect(privacy.sql).toContain('FROM "AlertOwnerTeam"');
|
|
1003
|
+
expect(privacy.params).toEqual([userId.toString()]);
|
|
1004
|
+
expectNoWrites(writeSpies);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("an API key (no user) gets the no-owner privacy clause, which hides every private alert", async (): Promise<void> => {
|
|
1008
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1009
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
1010
|
+
[createAlertRow(alertId)],
|
|
1011
|
+
[],
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
const promise: Promise<void> =
|
|
1015
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1016
|
+
projectId,
|
|
1017
|
+
alertIds: [alertId],
|
|
1018
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }], {
|
|
1019
|
+
withoutUser: true,
|
|
1020
|
+
userType: UserType.API,
|
|
1021
|
+
}),
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
1025
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
1026
|
+
|
|
1027
|
+
const privacy: RawClause = getRawClause(
|
|
1028
|
+
getQueryValue(getFindByArgument(findBySpy, 1).query, "isPrivate"),
|
|
1029
|
+
);
|
|
1030
|
+
expect(privacy.sql).toBe("(COLUMN IS NULL OR COLUMN = FALSE)");
|
|
1031
|
+
expect(privacy.params).toEqual([]);
|
|
1032
|
+
expectNoWrites(writeSpies);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test("a ProjectOwner bypasses alert privacy: the scoped read has no privacy clause", async (): Promise<void> => {
|
|
1036
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1037
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
1038
|
+
[createAlertRow(alertId)],
|
|
1039
|
+
[createPermittedRow(alertId)],
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
await expect(
|
|
1043
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1044
|
+
projectId,
|
|
1045
|
+
alertIds: [alertId],
|
|
1046
|
+
props: createDatabaseProps([{ permission: Permission.ProjectOwner }]),
|
|
1047
|
+
}),
|
|
1048
|
+
).resolves.toBeUndefined();
|
|
1049
|
+
|
|
1050
|
+
const permittedQuery: Query<Alert> = getFindByArgument(
|
|
1051
|
+
findBySpy,
|
|
1052
|
+
1,
|
|
1053
|
+
).query;
|
|
1054
|
+
expect(Object.keys(permittedQuery)).not.toContain("isPrivate");
|
|
1055
|
+
expect(
|
|
1056
|
+
getRawParameterValues(getQueryValue(permittedQuery, "_id")),
|
|
1057
|
+
).toEqual([[alertId.toString()]]);
|
|
1058
|
+
expectNoWrites(writeSpies);
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
describe("matching the scoped read to the requested ids", (): void => {
|
|
1063
|
+
test("ids are compared case-insensitively: upper-case requested ids match lower-case rows", async (): Promise<void> => {
|
|
1064
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1065
|
+
const upperCaseId: ObjectID = new ObjectID(
|
|
1066
|
+
alertId.toString().toUpperCase(),
|
|
1067
|
+
);
|
|
1068
|
+
stubAlertReads(
|
|
1069
|
+
[createAlertRow(alertId)],
|
|
1070
|
+
[createPermittedRow(alertId.toString().toLowerCase())],
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
await expect(
|
|
1074
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1075
|
+
projectId,
|
|
1076
|
+
alertIds: [upperCaseId],
|
|
1077
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }]),
|
|
1078
|
+
}),
|
|
1079
|
+
).resolves.toBeUndefined();
|
|
1080
|
+
expectNoWrites(writeSpies);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("ids are compared case-insensitively: lower-case requested ids match upper-case rows", async (): Promise<void> => {
|
|
1084
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1085
|
+
stubAlertReads(
|
|
1086
|
+
[createAlertRow(alertId)],
|
|
1087
|
+
[createPermittedRow(alertId.toString().toUpperCase())],
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
await expect(
|
|
1091
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1092
|
+
projectId,
|
|
1093
|
+
alertIds: [alertId],
|
|
1094
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }]),
|
|
1095
|
+
}),
|
|
1096
|
+
).resolves.toBeUndefined();
|
|
1097
|
+
expectNoWrites(writeSpies);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test("a repeated id needs only one permitted row", async (): Promise<void> => {
|
|
1101
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1102
|
+
stubAlertReads([createAlertRow(alertId)], [createPermittedRow(alertId)]);
|
|
1103
|
+
|
|
1104
|
+
await expect(
|
|
1105
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1106
|
+
projectId,
|
|
1107
|
+
alertIds: [alertId, new ObjectID(alertId.toString())],
|
|
1108
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }]),
|
|
1109
|
+
}),
|
|
1110
|
+
).resolves.toBeUndefined();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test("a permitted row without an _id does not stand in for any alert", async (): Promise<void> => {
|
|
1114
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1115
|
+
stubAlertReads([createAlertRow(alertId)], [new Alert()]);
|
|
1116
|
+
|
|
1117
|
+
await expect(
|
|
1118
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1119
|
+
projectId,
|
|
1120
|
+
alertIds: [alertId],
|
|
1121
|
+
props: createDatabaseProps([{ permission: Permission.AlertMember }]),
|
|
1122
|
+
}),
|
|
1123
|
+
).rejects.toThrow(REFUSAL_MESSAGE);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("an empty scoped read refuses even a ProjectOwner", async (): Promise<void> => {
|
|
1127
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1128
|
+
stubAlertReads([createAlertRow(alertId)], []);
|
|
1129
|
+
|
|
1130
|
+
const promise: Promise<void> =
|
|
1131
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1132
|
+
projectId,
|
|
1133
|
+
alertIds: [alertId],
|
|
1134
|
+
props: createDatabaseProps([{ permission: Permission.ProjectOwner }]),
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
await expect(promise).rejects.toBeInstanceOf(NotAuthorizedException);
|
|
1138
|
+
await expect(promise).rejects.toThrow(REFUSAL_MESSAGE);
|
|
1139
|
+
expectNoWrites(writeSpies);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
describe("master admin", (): void => {
|
|
1144
|
+
test("a master admin (not root) still goes through both reads, and the permission layer lets every check through", async (): Promise<void> => {
|
|
1145
|
+
const alertId: ObjectID = ObjectID.generate();
|
|
1146
|
+
const findBySpy: FindBySpy = stubAlertReads(
|
|
1147
|
+
[createAlertRow(alertId)],
|
|
1148
|
+
[createPermittedRow(alertId)],
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
await expect(
|
|
1152
|
+
AlertStateChangeAuthorization.assertCanChangeStateOfAlerts({
|
|
1153
|
+
projectId,
|
|
1154
|
+
alertIds: [alertId],
|
|
1155
|
+
props: {
|
|
1156
|
+
isMasterAdmin: true,
|
|
1157
|
+
userId,
|
|
1158
|
+
userType: UserType.MasterAdmin,
|
|
1159
|
+
tenantId: projectId,
|
|
1160
|
+
},
|
|
1161
|
+
}),
|
|
1162
|
+
).resolves.toBeUndefined();
|
|
1163
|
+
|
|
1164
|
+
expect(findBySpy).toHaveBeenCalledTimes(2);
|
|
1165
|
+
// No privacy clause for a master admin.
|
|
1166
|
+
expect(
|
|
1167
|
+
getQueryValue(getFindByArgument(findBySpy, 1).query, "isPrivate"),
|
|
1168
|
+
).toBeUndefined();
|
|
1169
|
+
expectNoWrites(writeSpies);
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
});
|