@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,973 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTAINER_SPECIFICITY_RANKS,
|
|
3
|
+
NESTABLE_CHILD_TYPE_LIST,
|
|
4
|
+
NESTING_RELATIONSHIP_RANKS,
|
|
5
|
+
PLACEMENT_RELATIONSHIP_TYPE_LIST,
|
|
6
|
+
SERVICE_MAP_ATTRIBUTE_KEY_LIST,
|
|
7
|
+
TopologySqlStatement,
|
|
8
|
+
TopologyWinnerScope,
|
|
9
|
+
activeSql,
|
|
10
|
+
collectionPageStatement,
|
|
11
|
+
collectionSearchStatement,
|
|
12
|
+
duplicateKeysStatement,
|
|
13
|
+
entitySectionsStatement,
|
|
14
|
+
entityStatement,
|
|
15
|
+
epochMsSql,
|
|
16
|
+
escapeLikeTerm,
|
|
17
|
+
infrastructureNodesStatement,
|
|
18
|
+
infrastructureTypeCountsStatement,
|
|
19
|
+
itemsByKeysStatement,
|
|
20
|
+
placementsStatement,
|
|
21
|
+
projectTypesStatement,
|
|
22
|
+
serviceMapDependenciesStatement,
|
|
23
|
+
serviceMapEntitiesStatement,
|
|
24
|
+
servicesStatement,
|
|
25
|
+
} from "../../../../Server/Utils/Topology/TopologySql";
|
|
26
|
+
import EntityRelationshipType from "../../../../Types/Telemetry/EntityRelationshipType";
|
|
27
|
+
import EntityType from "../../../../Types/Telemetry/EntityType";
|
|
28
|
+
import {
|
|
29
|
+
CONTAINER_SPECIFICITY,
|
|
30
|
+
NESTABLE_CHILD_TYPES,
|
|
31
|
+
NESTING_RELATIONSHIP_PRIORITY,
|
|
32
|
+
PLACEMENT_RELATIONSHIP_TYPES,
|
|
33
|
+
SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS,
|
|
34
|
+
} from "../../../../Types/Topology/TopologyTypeRules";
|
|
35
|
+
import { describe, expect, test } from "@jest/globals";
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* The Topology API's statements, checked as text and parameters without a
|
|
39
|
+
* database (TopologyQueriesPostgres runs them for real). What is pinned here
|
|
40
|
+
* is what a reviewer would otherwise have to re-derive from every string:
|
|
41
|
+
*
|
|
42
|
+
* - the project is always bound, as $1, and never spliced into the text;
|
|
43
|
+
* - every table reference is scoped to live rows (deletedAt, and for items
|
|
44
|
+
* isArchived) — including the aliases inside sub-selects — and every
|
|
45
|
+
* relationship reference to the range, except in the all-time reads;
|
|
46
|
+
* - nothing a request supplies reaches the text, however hostile;
|
|
47
|
+
* - every parameter is referenced (Postgres rejects an unreferenced one
|
|
48
|
+
* with "could not determine data type of parameter") and every
|
|
49
|
+
* placeholder has a value;
|
|
50
|
+
* - the activity predicate is exactly the Dashboard's isEntityActive;
|
|
51
|
+
* - the nesting ranks are bound from TopologyTypeRules, never copied.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const PROJECT_ID: string = "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e";
|
|
55
|
+
const RANGE_START: string = "2026-09-20T10:00:00.000Z";
|
|
56
|
+
|
|
57
|
+
/* Request-supplied values that would break the statement if interpolated. */
|
|
58
|
+
const HOSTILE: string = `x'); DROP TABLE "InventoryItem"; -- $1 "quoted"`;
|
|
59
|
+
|
|
60
|
+
const NO_DUPLICATES: TopologyWinnerScope = {
|
|
61
|
+
projectTypes: [EntityType.Service, EntityType.KubernetesPod],
|
|
62
|
+
duplicateKeys: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const WITH_DUPLICATES: TopologyWinnerScope = {
|
|
66
|
+
projectTypes: [EntityType.Service, EntityType.KubernetesPod],
|
|
67
|
+
duplicateKeys: ["twin"],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function normalize(sql: string): string {
|
|
71
|
+
return sql.replace(/\s+/g, " ").trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function placeholders(sql: string): Array<number> {
|
|
75
|
+
const found: Set<number> = new Set<number>();
|
|
76
|
+
for (const match of sql.matchAll(/\$(\d+)/g)) {
|
|
77
|
+
found.add(Number(match[1]));
|
|
78
|
+
}
|
|
79
|
+
return Array.from(found).sort((left: number, right: number): number => {
|
|
80
|
+
return left - right;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* The tables every statement here reads, and what scopes each reference. */
|
|
85
|
+
const TABLE_PATTERN: RegExp = /"(InventoryItem|InventoryItemRelationship)"/g;
|
|
86
|
+
const REFERENCE_PATTERN: RegExp =
|
|
87
|
+
/"(InventoryItem|InventoryItemRelationship)" ([a-z_]+)/g;
|
|
88
|
+
|
|
89
|
+
interface TableReference {
|
|
90
|
+
table: string;
|
|
91
|
+
alias: string;
|
|
92
|
+
at: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Every `"Table" alias` in the text, where it is. */
|
|
96
|
+
function referencesOf(sql: string): Array<TableReference> {
|
|
97
|
+
const references: Array<TableReference> = [];
|
|
98
|
+
for (const match of sql.matchAll(REFERENCE_PATTERN)) {
|
|
99
|
+
references.push({
|
|
100
|
+
table: match[1] as string,
|
|
101
|
+
alias: match[2] as string,
|
|
102
|
+
at: match.index as number,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return references;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Parenthesized groups as [open, close] offsets; string literals skipped. */
|
|
109
|
+
function groupsOf(sql: string): Array<[number, number]> {
|
|
110
|
+
const groups: Array<[number, number]> = [];
|
|
111
|
+
const open: Array<number> = [];
|
|
112
|
+
let inLiteral: boolean = false;
|
|
113
|
+
for (let index: number = 0; index < sql.length; index++) {
|
|
114
|
+
const character: string = sql[index] as string;
|
|
115
|
+
if (character === "'") {
|
|
116
|
+
inLiteral = !inLiteral;
|
|
117
|
+
} else if (!inLiteral && character === "(") {
|
|
118
|
+
open.push(index);
|
|
119
|
+
} else if (!inLiteral && character === ")") {
|
|
120
|
+
const start: number | undefined = open.pop();
|
|
121
|
+
if (start === undefined) {
|
|
122
|
+
throw new Error(`unbalanced ")" at ${index}`);
|
|
123
|
+
}
|
|
124
|
+
groups.push([start, index]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (open.length > 0 || inLiteral) {
|
|
128
|
+
throw new Error("unbalanced statement text");
|
|
129
|
+
}
|
|
130
|
+
return groups;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* The text a table reference's own predicates must be in: the innermost
|
|
135
|
+
* parenthesized group around it (its sub-select or CTE body; the whole
|
|
136
|
+
* statement at the top level), minus every group nested in it that reads a
|
|
137
|
+
* table itself — so a predicate on another reading of the same table, or on
|
|
138
|
+
* a sub-select's own alias, can never stand in for this one's.
|
|
139
|
+
*/
|
|
140
|
+
function ownScopeOf(
|
|
141
|
+
sql: string,
|
|
142
|
+
groups: Array<[number, number]>,
|
|
143
|
+
references: Array<TableReference>,
|
|
144
|
+
reference: TableReference,
|
|
145
|
+
): { key: string; text: string } {
|
|
146
|
+
let scope: [number, number] = [-1, sql.length];
|
|
147
|
+
for (const group of groups) {
|
|
148
|
+
if (
|
|
149
|
+
group[0] < reference.at &&
|
|
150
|
+
reference.at < group[1] &&
|
|
151
|
+
group[0] > scope[0]
|
|
152
|
+
) {
|
|
153
|
+
scope = group;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const nestedReaders: Array<[number, number]> = groups
|
|
157
|
+
.filter((group: [number, number]): boolean => {
|
|
158
|
+
return (
|
|
159
|
+
group[0] > scope[0] &&
|
|
160
|
+
group[1] < scope[1] &&
|
|
161
|
+
references.some((other: TableReference): boolean => {
|
|
162
|
+
return group[0] < other.at && other.at < group[1];
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
})
|
|
166
|
+
.sort((left: [number, number], right: [number, number]): number => {
|
|
167
|
+
return left[0] - right[0];
|
|
168
|
+
});
|
|
169
|
+
let text: string = "";
|
|
170
|
+
let cursor: number = scope[0] + 1;
|
|
171
|
+
for (const group of nestedReaders) {
|
|
172
|
+
if (group[0] < cursor) {
|
|
173
|
+
/* Inside a group already cut out. */
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
text += `${sql.slice(cursor, group[0])} (...) `;
|
|
177
|
+
cursor = group[1] + 1;
|
|
178
|
+
}
|
|
179
|
+
text += sql.slice(cursor, scope[1]);
|
|
180
|
+
return { key: `${scope[0]}:${scope[1]}`, text };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function countOf(text: string, pattern: string): number {
|
|
184
|
+
return Array.from(text.matchAll(new RegExp(pattern, "g"))).length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeRegExp(value: string): string {
|
|
188
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/*
|
|
192
|
+
* The live-row predicates every reading of a table needs, as patterns on its
|
|
193
|
+
* alias (not preceded by another identifier character, so `i` never matches
|
|
194
|
+
* inside `dup`).
|
|
195
|
+
*/
|
|
196
|
+
interface WellFormedOptions {
|
|
197
|
+
itemsMayIncludeArchived?: boolean;
|
|
198
|
+
/* The all-time reads: relationships however long ago they were seen. */
|
|
199
|
+
relationshipsAllTime?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function requiredPredicates(
|
|
203
|
+
reference: TableReference,
|
|
204
|
+
options: WellFormedOptions,
|
|
205
|
+
): Array<string> {
|
|
206
|
+
const alias: string = `(?<![A-Za-z0-9_"])${escapeRegExp(reference.alias)}`;
|
|
207
|
+
const predicates: Array<string> = [
|
|
208
|
+
`${alias}\\."projectId" = \\$1(?!\\d)`,
|
|
209
|
+
`${alias}\\."deletedAt" IS NULL`,
|
|
210
|
+
];
|
|
211
|
+
if (reference.table === "InventoryItemRelationship") {
|
|
212
|
+
if (!options.relationshipsAllTime) {
|
|
213
|
+
predicates.push(`${alias}\\."lastSeenAt" >= \\$\\d+`);
|
|
214
|
+
}
|
|
215
|
+
} else if (!options.itemsMayIncludeArchived) {
|
|
216
|
+
predicates.push(`${alias}\\."isArchived" = false`);
|
|
217
|
+
}
|
|
218
|
+
return predicates;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function expectWellFormed(
|
|
222
|
+
statement: TopologySqlStatement,
|
|
223
|
+
options: WellFormedOptions = {},
|
|
224
|
+
): void {
|
|
225
|
+
const sql: string = normalize(statement.sql);
|
|
226
|
+
|
|
227
|
+
// Placeholders are exactly $1..$n, each with a value.
|
|
228
|
+
const used: Array<number> = placeholders(sql);
|
|
229
|
+
expect(used).toEqual(
|
|
230
|
+
statement.params.map((_value: unknown, index: number): number => {
|
|
231
|
+
return index + 1;
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// The project is the first parameter and appears nowhere in the text.
|
|
236
|
+
expect(statement.params[0]).toBe(PROJECT_ID);
|
|
237
|
+
expect(sql).not.toContain(PROJECT_ID);
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
* Every reading of a table is scoped by predicates of its OWN: counted per
|
|
241
|
+
* reference, inside the sub-select or CTE that reads it. Two readings of
|
|
242
|
+
* one table under one alias (the drawer's outbound and inbound scans) each
|
|
243
|
+
* need their own set; one set elsewhere in the statement proves nothing.
|
|
244
|
+
*/
|
|
245
|
+
const references: Array<TableReference> = referencesOf(sql);
|
|
246
|
+
expect(references.length).toBeGreaterThan(0);
|
|
247
|
+
// Every table is read through an alias the checks below can follow.
|
|
248
|
+
expect(Array.from(sql.matchAll(TABLE_PATTERN)).length).toBe(
|
|
249
|
+
references.length,
|
|
250
|
+
);
|
|
251
|
+
const groups: Array<[number, number]> = groupsOf(sql);
|
|
252
|
+
const needed: Map<string, { text: string; count: number }> = new Map<
|
|
253
|
+
string,
|
|
254
|
+
{ text: string; count: number }
|
|
255
|
+
>();
|
|
256
|
+
for (const reference of references) {
|
|
257
|
+
const scope: { key: string; text: string } = ownScopeOf(
|
|
258
|
+
sql,
|
|
259
|
+
groups,
|
|
260
|
+
references,
|
|
261
|
+
reference,
|
|
262
|
+
);
|
|
263
|
+
for (const predicate of requiredPredicates(reference, options)) {
|
|
264
|
+
const key: string = `${scope.key}|${predicate}`;
|
|
265
|
+
const entry: { text: string; count: number } = needed.get(key) || {
|
|
266
|
+
text: scope.text,
|
|
267
|
+
count: 0,
|
|
268
|
+
};
|
|
269
|
+
entry.count++;
|
|
270
|
+
needed.set(key, entry);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const [key, entry] of needed) {
|
|
274
|
+
const predicate: string = key.slice(key.indexOf("|") + 1);
|
|
275
|
+
expect({
|
|
276
|
+
predicate,
|
|
277
|
+
scope: entry.text,
|
|
278
|
+
found: countOf(entry.text, predicate) >= entry.count,
|
|
279
|
+
}).toEqual({ predicate, scope: entry.text, found: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Nothing request-supplied is spliced into the text.
|
|
283
|
+
expect(sql).not.toContain("DROP TABLE");
|
|
284
|
+
expect(sql).not.toContain(RANGE_START);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
describe("TopologySql fragments", () => {
|
|
288
|
+
test("the activity predicate is the Dashboard's isEntityActive, exactly", () => {
|
|
289
|
+
expect(activeSql("i", "$2")).toBe(
|
|
290
|
+
`CASE WHEN i."source" IS NOT NULL AND i."source" <> '' AND i."source" <> 'discovered' THEN TRUE ` +
|
|
291
|
+
`WHEN i."lastSeenAt" IS NULL THEN TRUE ELSE i."lastSeenAt" >= $2 END`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("timestamps leave the database as floored epoch milliseconds", () => {
|
|
296
|
+
expect(epochMsSql(`i."lastSeenAt"`)).toBe(
|
|
297
|
+
`floor(extract(epoch from i."lastSeenAt") * 1000)::float8`,
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test.each([
|
|
302
|
+
["plain", "plain"],
|
|
303
|
+
["100%", "100\\%"],
|
|
304
|
+
["a_b", "a\\_b"],
|
|
305
|
+
["back\\slash", "back\\\\slash"],
|
|
306
|
+
["%_\\", "\\%\\_\\\\"],
|
|
307
|
+
["", ""],
|
|
308
|
+
])("escapeLikeTerm(%j) = %j", (term: string, escaped: string) => {
|
|
309
|
+
expect(escapeLikeTerm(term)).toBe(escaped);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("the nesting ranks are bound from TopologyTypeRules", () => {
|
|
313
|
+
const priorities: Record<string, number> = {};
|
|
314
|
+
NESTING_RELATIONSHIP_RANKS.keys.forEach(
|
|
315
|
+
(key: string, index: number): void => {
|
|
316
|
+
priorities[key] = NESTING_RELATIONSHIP_RANKS.ranks[index] as number;
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
expect(priorities).toEqual(NESTING_RELATIONSHIP_PRIORITY);
|
|
320
|
+
|
|
321
|
+
const specificities: Record<string, number> = {};
|
|
322
|
+
CONTAINER_SPECIFICITY_RANKS.keys.forEach(
|
|
323
|
+
(key: string, index: number): void => {
|
|
324
|
+
specificities[key] = CONTAINER_SPECIFICITY_RANKS.ranks[index] as number;
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
expect(specificities).toEqual(CONTAINER_SPECIFICITY);
|
|
328
|
+
|
|
329
|
+
expect(new Set<string>(NESTABLE_CHILD_TYPE_LIST)).toEqual(
|
|
330
|
+
new Set<string>(NESTABLE_CHILD_TYPES),
|
|
331
|
+
);
|
|
332
|
+
expect(new Set<string>(PLACEMENT_RELATIONSHIP_TYPE_LIST)).toEqual(
|
|
333
|
+
new Set<string>(PLACEMENT_RELATIONSHIP_TYPES),
|
|
334
|
+
);
|
|
335
|
+
expect(SERVICE_MAP_ATTRIBUTE_KEY_LIST).toEqual([
|
|
336
|
+
...SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS,
|
|
337
|
+
]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("TopologySql statements", () => {
|
|
342
|
+
test("project types: a loose index scan over live rows", () => {
|
|
343
|
+
const statement: TopologySqlStatement = projectTypesStatement({
|
|
344
|
+
projectId: PROJECT_ID,
|
|
345
|
+
});
|
|
346
|
+
/*
|
|
347
|
+
* Archived rows are deliberately not excluded: this list only widens
|
|
348
|
+
* `"entityType" = ANY(...)` so the unique index is usable, and checking
|
|
349
|
+
* isArchived would cost a heap visit per type.
|
|
350
|
+
*/
|
|
351
|
+
expectWellFormed(statement, { itemsMayIncludeArchived: true });
|
|
352
|
+
expect(statement.sql).toContain("WITH RECURSIVE");
|
|
353
|
+
expect(statement.params).toEqual([PROJECT_ID]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("duplicate keys: live rows grouped by key", () => {
|
|
357
|
+
const statement: TopologySqlStatement = duplicateKeysStatement({
|
|
358
|
+
projectId: PROJECT_ID,
|
|
359
|
+
});
|
|
360
|
+
expectWellFormed(statement);
|
|
361
|
+
expect(normalize(statement.sql)).toContain(
|
|
362
|
+
`GROUP BY i."entityKey" HAVING COUNT(*) > 1`,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("the winner rule", () => {
|
|
367
|
+
test("adds nothing when no key is duplicated", () => {
|
|
368
|
+
const statement: TopologySqlStatement = servicesStatement({
|
|
369
|
+
projectId: PROJECT_ID,
|
|
370
|
+
winner: NO_DUPLICATES,
|
|
371
|
+
});
|
|
372
|
+
expectWellFormed(statement);
|
|
373
|
+
expect(statement.sql).not.toContain("NOT EXISTS");
|
|
374
|
+
// The project types are only bound when something uses them.
|
|
375
|
+
expect(statement.params).toEqual([PROJECT_ID, EntityType.Service]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("resolves only the duplicated keys, by createdAt ASC then _id DESC", () => {
|
|
379
|
+
const statement: TopologySqlStatement = servicesStatement({
|
|
380
|
+
projectId: PROJECT_ID,
|
|
381
|
+
winner: WITH_DUPLICATES,
|
|
382
|
+
});
|
|
383
|
+
expectWellFormed(statement);
|
|
384
|
+
const sql: string = normalize(statement.sql);
|
|
385
|
+
expect(sql).toContain(`(NOT (i."entityKey" = ANY($`);
|
|
386
|
+
expect(sql).toContain(`OR NOT EXISTS (SELECT 1 FROM "InventoryItem" dup`);
|
|
387
|
+
expect(sql).toContain(
|
|
388
|
+
`AND (dup."createdAt" < i."createdAt" OR (dup."createdAt" = i."createdAt" AND dup."_id" > i."_id"))`,
|
|
389
|
+
);
|
|
390
|
+
expect(statement.params).toContainEqual(["twin"]);
|
|
391
|
+
expect(statement.params).toContainEqual(WITH_DUPLICATES.projectTypes);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test.each([
|
|
396
|
+
["services", undefined],
|
|
397
|
+
["callees", [HOSTILE, "db-1"]],
|
|
398
|
+
])(
|
|
399
|
+
"service map entities (%s)",
|
|
400
|
+
(_label: string, keys: Array<string> | undefined) => {
|
|
401
|
+
for (const winner of [NO_DUPLICATES, WITH_DUPLICATES]) {
|
|
402
|
+
for (const mode of ["rows", "count"] as const) {
|
|
403
|
+
const statement: TopologySqlStatement = serviceMapEntitiesStatement({
|
|
404
|
+
projectId: PROJECT_ID,
|
|
405
|
+
winner,
|
|
406
|
+
keys,
|
|
407
|
+
mode,
|
|
408
|
+
limit: 51,
|
|
409
|
+
});
|
|
410
|
+
expectWellFormed(statement);
|
|
411
|
+
const sql: string = normalize(statement.sql);
|
|
412
|
+
if (keys) {
|
|
413
|
+
expect(statement.params).toContainEqual(keys);
|
|
414
|
+
expect(sql).toContain(`i."entityType" <> $`);
|
|
415
|
+
expect(sql).toContain(`i."entityType" = ANY($`);
|
|
416
|
+
}
|
|
417
|
+
if (mode === "rows") {
|
|
418
|
+
expect(sql).toContain(`ORDER BY i."entityKey" COLLATE "C" ASC`);
|
|
419
|
+
expect(statement.params[statement.params.length - 1]).toBe(51);
|
|
420
|
+
expect(statement.params).toContainEqual(
|
|
421
|
+
SERVICE_MAP_ATTRIBUTE_KEY_LIST,
|
|
422
|
+
);
|
|
423
|
+
expect(sql).toContain(
|
|
424
|
+
`WHERE jsonb_typeof(i."descriptiveAttributes" -> k.key) = 'string'`,
|
|
425
|
+
);
|
|
426
|
+
expect(sql).toContain(
|
|
427
|
+
`WHERE jsonb_typeof(i."identifyingAttributes" -> k.key) = 'string'`,
|
|
428
|
+
);
|
|
429
|
+
} else {
|
|
430
|
+
expect(sql).toContain(`SELECT COUNT(*)::int AS "total"`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
test("service map dependencies: depends-on from the services, old list order", () => {
|
|
438
|
+
for (const mode of ["rows", "count"] as const) {
|
|
439
|
+
const statement: TopologySqlStatement = serviceMapDependenciesStatement({
|
|
440
|
+
projectId: PROJECT_ID,
|
|
441
|
+
rangeStart: RANGE_START,
|
|
442
|
+
serviceKeys: [HOSTILE],
|
|
443
|
+
mode,
|
|
444
|
+
limit: 10,
|
|
445
|
+
});
|
|
446
|
+
expectWellFormed(statement);
|
|
447
|
+
const sql: string = normalize(statement.sql);
|
|
448
|
+
expect(statement.params[1]).toBe(RANGE_START);
|
|
449
|
+
expect(statement.params).toContain(EntityRelationshipType.DependsOn);
|
|
450
|
+
expect(statement.params).toContainEqual([HOSTILE]);
|
|
451
|
+
expect(sql).toContain(`r."fromEntityKey" <> r."toEntityKey"`);
|
|
452
|
+
if (mode === "rows") {
|
|
453
|
+
expect(sql).toContain(`ORDER BY r."createdAt" DESC, r."_id" ASC`);
|
|
454
|
+
}
|
|
455
|
+
/* The Service Map reads every callee: no callee filter. */
|
|
456
|
+
expect(sql).not.toContain(`r."toEntityKey" = ANY(`);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("infrastructure dependencies: only calls into the given callees, bound as an array", () => {
|
|
461
|
+
for (const mode of ["rows", "count"] as const) {
|
|
462
|
+
const statement: TopologySqlStatement = serviceMapDependenciesStatement({
|
|
463
|
+
projectId: PROJECT_ID,
|
|
464
|
+
rangeStart: RANGE_START,
|
|
465
|
+
serviceKeys: ["svc-a", HOSTILE],
|
|
466
|
+
calleeKeys: ["svc-b", HOSTILE],
|
|
467
|
+
mode,
|
|
468
|
+
limit: 10,
|
|
469
|
+
});
|
|
470
|
+
expectWellFormed(statement);
|
|
471
|
+
const sql: string = normalize(statement.sql);
|
|
472
|
+
expect(statement.params).toContainEqual(["svc-a", HOSTILE]);
|
|
473
|
+
expect(statement.params).toContainEqual(["svc-b", HOSTILE]);
|
|
474
|
+
const callees: string = `$${
|
|
475
|
+
statement.params.findIndex((param: unknown): boolean => {
|
|
476
|
+
return (
|
|
477
|
+
Array.isArray(param) && (param as Array<string>)[0] === "svc-b"
|
|
478
|
+
);
|
|
479
|
+
}) + 1
|
|
480
|
+
}`;
|
|
481
|
+
expect(sql).toContain(`r."toEntityKey" = ANY(${callees}::text[])`);
|
|
482
|
+
expect(sql).toContain(`r."fromEntityKey" <> r."toEntityKey"`);
|
|
483
|
+
expect(sql).toContain(
|
|
484
|
+
`r."relationshipType" = $3 AND r."fromEntityKey" <> r."toEntityKey"`,
|
|
485
|
+
);
|
|
486
|
+
if (mode === "count") {
|
|
487
|
+
expect(sql).toContain(`SELECT COUNT(*)::int AS "total"`);
|
|
488
|
+
} else {
|
|
489
|
+
expect(sql).toContain(`ORDER BY r."createdAt" DESC, r."_id" ASC`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("placements: distinct service to target over runs-on / hosted-on", () => {
|
|
495
|
+
const statement: TopologySqlStatement = placementsStatement({
|
|
496
|
+
projectId: PROJECT_ID,
|
|
497
|
+
rangeStart: RANGE_START,
|
|
498
|
+
serviceKeys: ["svc-a"],
|
|
499
|
+
});
|
|
500
|
+
expectWellFormed(statement);
|
|
501
|
+
expect(normalize(statement.sql)).toContain(
|
|
502
|
+
`SELECT DISTINCT r."fromEntityKey" AS "service", r."toEntityKey" AS "target"`,
|
|
503
|
+
);
|
|
504
|
+
expect(statement.params).toContainEqual(PLACEMENT_RELATIONSHIP_TYPE_LIST);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("items by keys: the keys as an array, with activity", () => {
|
|
508
|
+
for (const winner of [NO_DUPLICATES, WITH_DUPLICATES]) {
|
|
509
|
+
const statement: TopologySqlStatement = itemsByKeysStatement({
|
|
510
|
+
projectId: PROJECT_ID,
|
|
511
|
+
rangeStart: RANGE_START,
|
|
512
|
+
winner,
|
|
513
|
+
keys: [HOSTILE],
|
|
514
|
+
});
|
|
515
|
+
expectWellFormed(statement);
|
|
516
|
+
const sql: string = normalize(statement.sql);
|
|
517
|
+
expect(sql).toContain(activeSql("i", "$2"));
|
|
518
|
+
expect(sql).toContain(`i."entityKey" = ANY($`);
|
|
519
|
+
expect(sql).toContain(`i."entityType" = ANY($3::text[])`);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("infrastructure type counts: one pass, exact counts per type", () => {
|
|
524
|
+
const statement: TopologySqlStatement = infrastructureTypeCountsStatement({
|
|
525
|
+
projectId: PROJECT_ID,
|
|
526
|
+
rangeStart: RANGE_START,
|
|
527
|
+
infrastructureTypes: [EntityType.Host, EntityType.NetworkDevice],
|
|
528
|
+
});
|
|
529
|
+
expectWellFormed(statement);
|
|
530
|
+
const sql: string = normalize(statement.sql);
|
|
531
|
+
expect(sql).toContain(
|
|
532
|
+
`COUNT(*) FILTER (WHERE ${activeSql("i", "$2")})::int`,
|
|
533
|
+
);
|
|
534
|
+
expect(sql).toContain(`GROUP BY i."entityType"`);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe("infrastructure nodes", () => {
|
|
538
|
+
const rows: TopologySqlStatement = infrastructureNodesStatement({
|
|
539
|
+
projectId: PROJECT_ID,
|
|
540
|
+
rangeStart: RANGE_START,
|
|
541
|
+
winner: WITH_DUPLICATES,
|
|
542
|
+
nodeTypes: [EntityType.KubernetesPod, EntityType.KubernetesNode],
|
|
543
|
+
mode: "rows",
|
|
544
|
+
limit: 200_001,
|
|
545
|
+
});
|
|
546
|
+
const sql: string = normalize(rows.sql);
|
|
547
|
+
|
|
548
|
+
test("is well formed in both modes", () => {
|
|
549
|
+
expectWellFormed(rows);
|
|
550
|
+
expectWellFormed(
|
|
551
|
+
infrastructureNodesStatement({
|
|
552
|
+
projectId: PROJECT_ID,
|
|
553
|
+
rangeStart: RANGE_START,
|
|
554
|
+
winner: NO_DUPLICATES,
|
|
555
|
+
nodeTypes: [EntityType.Host],
|
|
556
|
+
mode: "count",
|
|
557
|
+
limit: 0,
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("binds the ranking tables and the nestable types", () => {
|
|
563
|
+
expect(rows.params).toContainEqual(NESTING_RELATIONSHIP_RANKS.keys);
|
|
564
|
+
expect(rows.params).toContainEqual(NESTING_RELATIONSHIP_RANKS.ranks);
|
|
565
|
+
expect(rows.params).toContainEqual(CONTAINER_SPECIFICITY_RANKS.keys);
|
|
566
|
+
expect(rows.params).toContainEqual(CONTAINER_SPECIFICITY_RANKS.ranks);
|
|
567
|
+
expect(rows.params).toContainEqual(NESTABLE_CHILD_TYPE_LIST);
|
|
568
|
+
// No rank is written into the text.
|
|
569
|
+
expect(sql).not.toMatch(/THEN [0-9]/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("ranks priority, then specificity, then the parent key in code-point order", () => {
|
|
573
|
+
expect(sql).toContain(
|
|
574
|
+
`ORDER BY c."child" COLLATE "C", c."priority" DESC, c."specificity" DESC, c."parent" COLLATE "C" ASC`,
|
|
575
|
+
);
|
|
576
|
+
expect(sql).toContain(`DISTINCT ON (c."child" COLLATE "C")`);
|
|
577
|
+
expect(sql).toContain(`FROM candidates c WHERE c."parentActive"`);
|
|
578
|
+
expect(sql).toContain(`r."fromEntityKey" <> r."toEntityKey"`);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("orders nodes by type then key in code-point order", () => {
|
|
582
|
+
expect(sql).toContain(
|
|
583
|
+
`ORDER BY n."type" COLLATE "C" ASC, n."key" COLLATE "C" ASC`,
|
|
584
|
+
);
|
|
585
|
+
expect(rows.params[rows.params.length - 1]).toBe(200_001);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
describe("collections", () => {
|
|
590
|
+
test("a page: keyset on (name, key), filters and search terms bound", () => {
|
|
591
|
+
const statement: TopologySqlStatement = collectionPageStatement({
|
|
592
|
+
projectId: PROJECT_ID,
|
|
593
|
+
rangeStart: RANGE_START,
|
|
594
|
+
entityType: EntityType.NetworkDevice,
|
|
595
|
+
includeInactive: false,
|
|
596
|
+
nameTerms: ["core", HOSTILE, "100%"],
|
|
597
|
+
cursor: { name: HOSTILE, key: "k-1" },
|
|
598
|
+
mode: "rows",
|
|
599
|
+
limit: 51,
|
|
600
|
+
});
|
|
601
|
+
expectWellFormed(statement);
|
|
602
|
+
const sql: string = normalize(statement.sql);
|
|
603
|
+
// The range start is bound after the type, only because it is needed.
|
|
604
|
+
expect(sql).toContain(`(${activeSql("i", "$3")})`);
|
|
605
|
+
expect(statement.params.slice(0, 3)).toEqual([
|
|
606
|
+
PROJECT_ID,
|
|
607
|
+
EntityType.NetworkDevice,
|
|
608
|
+
RANGE_START,
|
|
609
|
+
]);
|
|
610
|
+
expect(sql).toContain(
|
|
611
|
+
`ORDER BY COALESCE(i."displayName", '') ASC, i."entityKey" COLLATE "C" ASC`,
|
|
612
|
+
);
|
|
613
|
+
expect(sql).toMatch(
|
|
614
|
+
/\(COALESCE\(i\."displayName", ''\) > \$\d+ OR \(COALESCE\(i\."displayName", ''\) = \$\d+ AND i\."entityKey" COLLATE "C" > \$\d+\)\)/,
|
|
615
|
+
);
|
|
616
|
+
expect(
|
|
617
|
+
sql.match(
|
|
618
|
+
/i\."displayName" ILIKE '%' \|\| \$\d+ \|\| '%' ESCAPE '\\'/g,
|
|
619
|
+
),
|
|
620
|
+
).toHaveLength(3);
|
|
621
|
+
expect(statement.params).toContain("100\\%");
|
|
622
|
+
expect(statement.params).toContain(escapeLikeTerm(HOSTILE));
|
|
623
|
+
expect(statement.params).toContain(HOSTILE);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("with inactive items included the range start is not bound", () => {
|
|
627
|
+
const statement: TopologySqlStatement = collectionPageStatement({
|
|
628
|
+
projectId: PROJECT_ID,
|
|
629
|
+
rangeStart: RANGE_START,
|
|
630
|
+
entityType: EntityType.NetworkDevice,
|
|
631
|
+
includeInactive: true,
|
|
632
|
+
nameTerms: [],
|
|
633
|
+
cursor: null,
|
|
634
|
+
mode: "count",
|
|
635
|
+
limit: 0,
|
|
636
|
+
});
|
|
637
|
+
expectWellFormed(statement);
|
|
638
|
+
expect(statement.params).toEqual([PROJECT_ID, EntityType.NetworkDevice]);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("search: one count per type, each with its own terms", () => {
|
|
642
|
+
const statement: TopologySqlStatement = collectionSearchStatement({
|
|
643
|
+
projectId: PROJECT_ID,
|
|
644
|
+
rangeStart: RANGE_START,
|
|
645
|
+
includeInactive: false,
|
|
646
|
+
types: [
|
|
647
|
+
{ entityType: EntityType.NetworkDevice, nameTerms: ["core"] },
|
|
648
|
+
{ entityType: EntityType.IoTDevice, nameTerms: [] },
|
|
649
|
+
],
|
|
650
|
+
});
|
|
651
|
+
expectWellFormed(statement);
|
|
652
|
+
const sql: string = normalize(statement.sql);
|
|
653
|
+
expect(sql.split(" UNION ALL ")).toHaveLength(2);
|
|
654
|
+
// One range start, shared by every branch.
|
|
655
|
+
expect(
|
|
656
|
+
statement.params.filter((value: unknown): boolean => {
|
|
657
|
+
return value === RANGE_START;
|
|
658
|
+
}),
|
|
659
|
+
).toHaveLength(1);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe("the drawer", () => {
|
|
664
|
+
test("the entity: by key, a known type preferred, then the winner order", () => {
|
|
665
|
+
const statement: TopologySqlStatement = entityStatement({
|
|
666
|
+
projectId: PROJECT_ID,
|
|
667
|
+
projectTypes: [EntityType.Service],
|
|
668
|
+
entityKey: HOSTILE,
|
|
669
|
+
entityType: null,
|
|
670
|
+
});
|
|
671
|
+
expectWellFormed(statement);
|
|
672
|
+
const sql: string = normalize(statement.sql);
|
|
673
|
+
expect(sql).toContain(
|
|
674
|
+
`ORDER BY (i."entityType" = $4::text) DESC NULLS LAST, i."createdAt" ASC, i."_id" DESC LIMIT 1`,
|
|
675
|
+
);
|
|
676
|
+
expect(statement.params).toEqual([
|
|
677
|
+
PROJECT_ID,
|
|
678
|
+
[EntityType.Service],
|
|
679
|
+
HOSTILE,
|
|
680
|
+
null,
|
|
681
|
+
]);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const sectionsFor: (
|
|
685
|
+
window: Parameters<typeof entitySectionsStatement>[0]["window"],
|
|
686
|
+
) => TopologySqlStatement = (
|
|
687
|
+
window: Parameters<typeof entitySectionsStatement>[0]["window"],
|
|
688
|
+
): TopologySqlStatement => {
|
|
689
|
+
return entitySectionsStatement({
|
|
690
|
+
projectId: PROJECT_ID,
|
|
691
|
+
scope: { kind: "range", rangeStart: RANGE_START },
|
|
692
|
+
projectTypes: [EntityType.Service],
|
|
693
|
+
entityKey: HOSTILE,
|
|
694
|
+
isService: true,
|
|
695
|
+
scanLimit: 100_001,
|
|
696
|
+
window,
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
test("sections: scan outbound then inbound, self-loops once, unknown ends kept", () => {
|
|
701
|
+
const statement: TopologySqlStatement = sectionsFor({
|
|
702
|
+
kind: "top",
|
|
703
|
+
dependencyRows: 100,
|
|
704
|
+
otherRows: 25,
|
|
705
|
+
});
|
|
706
|
+
expectWellFormed(statement);
|
|
707
|
+
const sql: string = normalize(statement.sql);
|
|
708
|
+
expect(sql).toContain(`r."fromEntityKey" = $4`);
|
|
709
|
+
expect(sql).toContain(`r."toEntityKey" = $4 AND r."fromEntityKey" <> $4`);
|
|
710
|
+
expect(sql).toContain(
|
|
711
|
+
`LIMIT GREATEST(0, $5::int - (SELECT COUNT(*)::int FROM scan_out))`,
|
|
712
|
+
);
|
|
713
|
+
expect(sql).toContain(`FROM scan s LEFT JOIN others o`);
|
|
714
|
+
expect(sql).toContain(`'Unnamed resource'`);
|
|
715
|
+
expect(sql).toContain(`'Undiscovered resource'`);
|
|
716
|
+
expect(statement.params).toContain(100_001);
|
|
717
|
+
expect(statement.params).toContain(true);
|
|
718
|
+
expect(statement.params).toContain(100);
|
|
719
|
+
expect(statement.params).toContain(25);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("sections: calls by traffic, the rest known-first, then label and key", () => {
|
|
723
|
+
const sql: string = normalize(
|
|
724
|
+
sectionsFor({ kind: "top", dependencyRows: 100, otherRows: 25 }).sql,
|
|
725
|
+
);
|
|
726
|
+
expect(sql).toContain(
|
|
727
|
+
`CASE WHEN c."section" IN ('calls', 'calledBy') THEN c."callCount" END DESC NULLS LAST, ` +
|
|
728
|
+
`CASE WHEN c."section" IN ('runsOn', 'related') THEN c."otherKnown" END DESC NULLS LAST, ` +
|
|
729
|
+
`c."label" ASC, c."otherKey" COLLATE "C" ASC`,
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("the entity all time: archived rows too, a live row before an archived one", () => {
|
|
734
|
+
const statement: TopologySqlStatement = entityStatement({
|
|
735
|
+
projectId: PROJECT_ID,
|
|
736
|
+
projectTypes: [EntityType.Service],
|
|
737
|
+
entityKey: HOSTILE,
|
|
738
|
+
entityType: EntityType.Service,
|
|
739
|
+
includeArchived: true,
|
|
740
|
+
});
|
|
741
|
+
expectWellFormed(statement, { itemsMayIncludeArchived: true });
|
|
742
|
+
const sql: string = normalize(statement.sql);
|
|
743
|
+
expect(sql).not.toContain(`"isArchived" = false`);
|
|
744
|
+
expect(sql).toContain(
|
|
745
|
+
`ORDER BY (i."entityType" = $4::text) DESC NULLS LAST, i."isArchived" ASC, i."createdAt" ASC, i."_id" DESC LIMIT 1`,
|
|
746
|
+
);
|
|
747
|
+
expect(statement.params).toEqual([
|
|
748
|
+
PROJECT_ID,
|
|
749
|
+
[EntityType.Service],
|
|
750
|
+
HOSTILE,
|
|
751
|
+
EntityType.Service,
|
|
752
|
+
]);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("sections all time: every stored relationship, archived other ends with their ids", () => {
|
|
756
|
+
const statement: TopologySqlStatement = entitySectionsStatement({
|
|
757
|
+
projectId: PROJECT_ID,
|
|
758
|
+
scope: { kind: "allTime" },
|
|
759
|
+
projectTypes: [EntityType.Service],
|
|
760
|
+
entityKey: HOSTILE,
|
|
761
|
+
isService: true,
|
|
762
|
+
scanLimit: 100_001,
|
|
763
|
+
window: { kind: "top", dependencyRows: 100, otherRows: 25 },
|
|
764
|
+
});
|
|
765
|
+
expectWellFormed(statement, {
|
|
766
|
+
itemsMayIncludeArchived: true,
|
|
767
|
+
relationshipsAllTime: true,
|
|
768
|
+
});
|
|
769
|
+
const sql: string = normalize(statement.sql);
|
|
770
|
+
// No range: nothing about lastSeenAt but the newest-first scan order.
|
|
771
|
+
expect(sql).not.toMatch(/"lastSeenAt" >=/);
|
|
772
|
+
expect(sql).not.toContain(`"isArchived" = false`);
|
|
773
|
+
expect(sql).toContain(
|
|
774
|
+
`WHERE r."projectId" = $1 AND r."deletedAt" IS NULL AND r."fromEntityKey" = $3 ORDER BY r."lastSeenAt" DESC, r."_id" ASC`,
|
|
775
|
+
);
|
|
776
|
+
expect(sql).toContain(
|
|
777
|
+
`WHERE r."projectId" = $1 AND r."deletedAt" IS NULL AND r."toEntityKey" = $3 AND r."fromEntityKey" <> $3`,
|
|
778
|
+
);
|
|
779
|
+
// The other end: its id, a live row winning a key an archived one shares.
|
|
780
|
+
expect(sql).toContain(`i."_id"::text AS "id"`);
|
|
781
|
+
expect(sql).toContain(
|
|
782
|
+
`ORDER BY i."entityKey", i."isArchived" ASC, i."createdAt" ASC, i."_id" DESC`,
|
|
783
|
+
);
|
|
784
|
+
expect(sql).toContain(`o."id" AS "otherId"`);
|
|
785
|
+
expect(sql).toContain(`'otherId', r."otherId"`);
|
|
786
|
+
expect(statement.params).not.toContain(RANGE_START);
|
|
787
|
+
expect(statement.params.slice(0, 4)).toEqual([
|
|
788
|
+
PROJECT_ID,
|
|
789
|
+
[EntityType.Service],
|
|
790
|
+
HOSTILE,
|
|
791
|
+
100_001,
|
|
792
|
+
]);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("sections in a range stay as they were: live other ends, no ids", () => {
|
|
796
|
+
const sql: string = normalize(
|
|
797
|
+
sectionsFor({ kind: "top", dependencyRows: 100, otherRows: 25 }).sql,
|
|
798
|
+
);
|
|
799
|
+
expect(sql).not.toContain("otherId");
|
|
800
|
+
expect(sql).not.toContain(`i."_id"::text`);
|
|
801
|
+
expect(sql).not.toContain(`"isArchived" ASC`);
|
|
802
|
+
expect(sql).toContain(
|
|
803
|
+
`ORDER BY i."entityKey", i."createdAt" ASC, i."_id" DESC`,
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("a page of one section is bound, not spliced", () => {
|
|
808
|
+
const statement: TopologySqlStatement = sectionsFor({
|
|
809
|
+
kind: "page",
|
|
810
|
+
section: "related",
|
|
811
|
+
offset: 25,
|
|
812
|
+
limit: 50,
|
|
813
|
+
});
|
|
814
|
+
expectWellFormed(statement);
|
|
815
|
+
expect(statement.params.slice(-3)).toEqual([25, "related", 50]);
|
|
816
|
+
expect(normalize(statement.sql)).toMatch(
|
|
817
|
+
/r\."section" = \$\d+::text AND r\."rank" > \$\d+::int AND r\."rank" <= \$\d+::int \+ \$\d+::int/,
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
/*
|
|
824
|
+
* The check above is only worth something if it fails on a statement that
|
|
825
|
+
* drops a predicate from ONE reading of a table while another reading keeps
|
|
826
|
+
* it — which a search over the whole text cannot see.
|
|
827
|
+
*/
|
|
828
|
+
describe("expectWellFormed catches a reading of a table without its own scope", () => {
|
|
829
|
+
const sections: TopologySqlStatement = entitySectionsStatement({
|
|
830
|
+
projectId: PROJECT_ID,
|
|
831
|
+
scope: { kind: "range", rangeStart: RANGE_START },
|
|
832
|
+
projectTypes: [EntityType.Service],
|
|
833
|
+
entityKey: "svc-a",
|
|
834
|
+
isService: true,
|
|
835
|
+
scanLimit: 100_001,
|
|
836
|
+
window: { kind: "top", dependencyRows: 100, otherRows: 25 },
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const allTimeSections: TopologySqlStatement = entitySectionsStatement({
|
|
840
|
+
projectId: PROJECT_ID,
|
|
841
|
+
scope: { kind: "allTime" },
|
|
842
|
+
projectTypes: [EntityType.Service],
|
|
843
|
+
entityKey: "svc-a",
|
|
844
|
+
isService: true,
|
|
845
|
+
scanLimit: 100_001,
|
|
846
|
+
window: { kind: "top", dependencyRows: 100, otherRows: 25 },
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const ALL_TIME: WellFormedOptions = {
|
|
850
|
+
itemsMayIncludeArchived: true,
|
|
851
|
+
relationshipsAllTime: true,
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
/* `statement` with `remove` taken out once, after the first `after`. */
|
|
855
|
+
function without(
|
|
856
|
+
statement: TopologySqlStatement,
|
|
857
|
+
after: string,
|
|
858
|
+
remove: string,
|
|
859
|
+
): TopologySqlStatement {
|
|
860
|
+
const sql: string = normalize(statement.sql);
|
|
861
|
+
const from: number = sql.indexOf(after);
|
|
862
|
+
expect(from).toBeGreaterThanOrEqual(0);
|
|
863
|
+
const at: number = sql.indexOf(remove, from);
|
|
864
|
+
expect(at).toBeGreaterThan(from);
|
|
865
|
+
return {
|
|
866
|
+
sql: sql.slice(0, at) + sql.slice(at + remove.length),
|
|
867
|
+
params: statement.params,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
test("the intact statements pass", () => {
|
|
872
|
+
expect(() => {
|
|
873
|
+
expectWellFormed(sections);
|
|
874
|
+
}).not.toThrow();
|
|
875
|
+
expect(() => {
|
|
876
|
+
expectWellFormed(allTimeSections, ALL_TIME);
|
|
877
|
+
}).not.toThrow();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test.each([
|
|
881
|
+
["scan_out", "the tenant", `r."projectId" = $1 AND `],
|
|
882
|
+
["scan_out", "the deleted filter", `r."deletedAt" IS NULL AND `],
|
|
883
|
+
["scan_in", "the tenant", `r."projectId" = $1 AND `],
|
|
884
|
+
["scan_in", "the deleted filter", `r."deletedAt" IS NULL AND `],
|
|
885
|
+
["others", "the tenant", `i."projectId" = $1 AND `],
|
|
886
|
+
["others", "the deleted filter", `i."deletedAt" IS NULL AND `],
|
|
887
|
+
])(
|
|
888
|
+
"the all-time %s without %s fails, though it reads no range",
|
|
889
|
+
(cte: string, _label: string, predicate: string) => {
|
|
890
|
+
expect(() => {
|
|
891
|
+
expectWellFormed(
|
|
892
|
+
without(allTimeSections, `${cte} AS MATERIALIZED (`, predicate),
|
|
893
|
+
ALL_TIME,
|
|
894
|
+
);
|
|
895
|
+
}).toThrow();
|
|
896
|
+
},
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
test.each([
|
|
900
|
+
["the tenant", `r."projectId" = $1 AND `],
|
|
901
|
+
["the deleted filter", `r."deletedAt" IS NULL AND `],
|
|
902
|
+
["the range", ` AND r."lastSeenAt" >= $2`],
|
|
903
|
+
])(
|
|
904
|
+
"the drawer's inbound scan without %s fails, though the outbound scan keeps it",
|
|
905
|
+
(_label: string, predicate: string) => {
|
|
906
|
+
const broken: TopologySqlStatement = without(
|
|
907
|
+
sections,
|
|
908
|
+
"scan_in AS MATERIALIZED (",
|
|
909
|
+
predicate,
|
|
910
|
+
);
|
|
911
|
+
// Still somewhere in the text: a whole-statement search would pass.
|
|
912
|
+
expect(broken.sql).toContain(predicate.replace(/^ AND | AND $/g, ""));
|
|
913
|
+
expect(() => {
|
|
914
|
+
expectWellFormed(broken);
|
|
915
|
+
}).toThrow();
|
|
916
|
+
},
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
test("the drawer's outbound scan without the tenant fails too", () => {
|
|
920
|
+
expect(() => {
|
|
921
|
+
expectWellFormed(
|
|
922
|
+
without(
|
|
923
|
+
sections,
|
|
924
|
+
"scan_out AS MATERIALIZED (",
|
|
925
|
+
`r."projectId" = $1 AND `,
|
|
926
|
+
),
|
|
927
|
+
);
|
|
928
|
+
}).toThrow();
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("the other-end lookup without the archive filter fails", () => {
|
|
932
|
+
expect(() => {
|
|
933
|
+
expectWellFormed(
|
|
934
|
+
without(
|
|
935
|
+
sections,
|
|
936
|
+
"others AS MATERIALIZED (",
|
|
937
|
+
`i."isArchived" = false AND `,
|
|
938
|
+
),
|
|
939
|
+
);
|
|
940
|
+
}).toThrow();
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("a winner sub-select cannot lend its predicates to the outer reading, nor the other way round", () => {
|
|
944
|
+
const services: TopologySqlStatement = servicesStatement({
|
|
945
|
+
projectId: PROJECT_ID,
|
|
946
|
+
winner: WITH_DUPLICATES,
|
|
947
|
+
});
|
|
948
|
+
expect(() => {
|
|
949
|
+
expectWellFormed(services);
|
|
950
|
+
}).not.toThrow();
|
|
951
|
+
expect(() => {
|
|
952
|
+
expectWellFormed(without(services, "SELECT", `i."deletedAt" IS NULL`));
|
|
953
|
+
}).toThrow();
|
|
954
|
+
expect(() => {
|
|
955
|
+
expectWellFormed(
|
|
956
|
+
without(
|
|
957
|
+
services,
|
|
958
|
+
`"InventoryItem" dup`,
|
|
959
|
+
`AND dup."deletedAt" IS NULL `,
|
|
960
|
+
),
|
|
961
|
+
);
|
|
962
|
+
}).toThrow();
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("a table read without an alias fails", () => {
|
|
966
|
+
expect(() => {
|
|
967
|
+
expectWellFormed({
|
|
968
|
+
sql: `SELECT COUNT(*) FROM "InventoryItem" i WHERE i."projectId" = $1 AND i."isArchived" = false AND i."deletedAt" IS NULL AND EXISTS (SELECT 1 FROM "InventoryItem" WHERE "projectId" = $1)`,
|
|
969
|
+
params: [PROJECT_ID],
|
|
970
|
+
});
|
|
971
|
+
}).toThrow();
|
|
972
|
+
});
|
|
973
|
+
});
|