@oneuptime/common 14.0.6 → 14.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- 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/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- 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/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -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/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -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/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/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/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- 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/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.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/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
import { PostgresStatementTimeoutMs } from "../../../../Server/EnvironmentConfig";
|
|
2
|
+
import InventoryItemService from "../../../../Server/Services/InventoryItemService";
|
|
3
|
+
import TopologyQueries, {
|
|
4
|
+
statementTimeoutSql,
|
|
5
|
+
} from "../../../../Server/Utils/Topology/TopologyQueries";
|
|
6
|
+
import {
|
|
7
|
+
CONTAINER_SPECIFICITY_RANKS,
|
|
8
|
+
NESTING_RELATIONSHIP_RANKS,
|
|
9
|
+
} from "../../../../Server/Utils/Topology/TopologySql";
|
|
10
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
11
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
12
|
+
import EntityType from "../../../../Types/Telemetry/EntityType";
|
|
13
|
+
import {
|
|
14
|
+
TOPOLOGY_API_FORMAT_VERSION,
|
|
15
|
+
TopologyApiLimits,
|
|
16
|
+
TopologyCollectionResponseJSON,
|
|
17
|
+
TopologyCollectionSearchResponseJSON,
|
|
18
|
+
TopologyConnectionRowJSON,
|
|
19
|
+
TopologyEntityAllTimeConnectionsResponseJSON,
|
|
20
|
+
TopologyEntityAllTimeResponseJSON,
|
|
21
|
+
TopologyEntityConnectionsResponseJSON,
|
|
22
|
+
TopologyEntityJSON,
|
|
23
|
+
TopologyEntityResponseJSON,
|
|
24
|
+
TopologyInfrastructureNodeJSON,
|
|
25
|
+
TopologyInfrastructureResponseJSON,
|
|
26
|
+
TopologyServiceMapEntityJSON,
|
|
27
|
+
TopologyServiceMapResponseJSON,
|
|
28
|
+
} from "../../../../Types/Topology/TopologyApi";
|
|
29
|
+
import {
|
|
30
|
+
afterEach,
|
|
31
|
+
beforeEach,
|
|
32
|
+
describe,
|
|
33
|
+
expect,
|
|
34
|
+
jest,
|
|
35
|
+
test,
|
|
36
|
+
} from "@jest/globals";
|
|
37
|
+
|
|
38
|
+
jest.mock("../../../../Server/Services/InventoryItemService", () => {
|
|
39
|
+
return {
|
|
40
|
+
__esModule: true,
|
|
41
|
+
default: { getRepository: jest.fn() },
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* TopologyQueries with the database replaced by a scripted runner: which
|
|
47
|
+
* statements run, in what order, inside what transaction, bound to which
|
|
48
|
+
* project — and how rows (as node-postgres returns them: bigint and numeric
|
|
49
|
+
* as strings, jsonb parsed, booleans) become the contract JSON. The SQL's own
|
|
50
|
+
* behaviour is TopologyQueriesPostgres's job.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const PROJECT_ID: ObjectID = ObjectID.generate();
|
|
54
|
+
const RANGE_START: Date = new Date("2026-09-20T10:00:00.000Z");
|
|
55
|
+
|
|
56
|
+
interface RecordedStatement {
|
|
57
|
+
sql: string;
|
|
58
|
+
params: Array<unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Responder = (sql: string, params: Array<unknown>) => Array<JSONObject>;
|
|
62
|
+
|
|
63
|
+
interface FakeDatabase {
|
|
64
|
+
statements: Array<RecordedStatement>;
|
|
65
|
+
isolationLevels: Array<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const repositoryMock: jest.Mock = (
|
|
69
|
+
InventoryItemService as unknown as { getRepository: jest.Mock }
|
|
70
|
+
).getRepository;
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* Routes each statement to the first rule whose pattern it contains. A
|
|
74
|
+
* statement no rule expects fails the test: a query the code should not have
|
|
75
|
+
* run is as much a regression as a wrong answer.
|
|
76
|
+
*/
|
|
77
|
+
function useDatabase(rules: Array<[string, Responder]>): FakeDatabase {
|
|
78
|
+
const database: FakeDatabase = { statements: [], isolationLevels: [] };
|
|
79
|
+
const query: (
|
|
80
|
+
sql: string,
|
|
81
|
+
params?: Array<unknown>,
|
|
82
|
+
) => Promise<unknown> = async (
|
|
83
|
+
sql: string,
|
|
84
|
+
params: Array<unknown> = [],
|
|
85
|
+
): Promise<unknown> => {
|
|
86
|
+
database.statements.push({ sql, params });
|
|
87
|
+
if (sql.startsWith("SET ")) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
for (const [pattern, responder] of rules) {
|
|
91
|
+
if (sql.includes(pattern)) {
|
|
92
|
+
return responder(sql, params);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`unexpected statement: ${sql.slice(0, 160)}`);
|
|
96
|
+
};
|
|
97
|
+
repositoryMock.mockReturnValue({
|
|
98
|
+
manager: {
|
|
99
|
+
transaction: async (
|
|
100
|
+
isolationLevel: string,
|
|
101
|
+
work: (manager: unknown) => Promise<unknown>,
|
|
102
|
+
): Promise<unknown> => {
|
|
103
|
+
database.isolationLevels.push(isolationLevel);
|
|
104
|
+
return await work({ query });
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return database;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rows(data: Array<JSONObject>): Responder {
|
|
112
|
+
return (): Array<JSONObject> => {
|
|
113
|
+
return data;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function typesRule(types: Array<string>): [string, Responder] {
|
|
118
|
+
return [
|
|
119
|
+
"WITH RECURSIVE project_types",
|
|
120
|
+
rows(
|
|
121
|
+
types.map((type: string): JSONObject => {
|
|
122
|
+
return { entityType: type };
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function duplicatesRule(keys: Array<string> = []): [string, Responder] {
|
|
129
|
+
return [
|
|
130
|
+
"HAVING COUNT(*) > 1",
|
|
131
|
+
rows(
|
|
132
|
+
keys.map((key: string): JSONObject => {
|
|
133
|
+
return { key };
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dataStatements(database: FakeDatabase): Array<RecordedStatement> {
|
|
140
|
+
return database.statements.filter((statement: RecordedStatement) => {
|
|
141
|
+
return !statement.sql.startsWith("SET ");
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scope(): { projectId: ObjectID; rangeStart: Date } {
|
|
146
|
+
return { projectId: PROJECT_ID, rangeStart: RANGE_START };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("TopologyQueries", () => {
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
repositoryMock.mockReset();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
jest.restoreAllMocks();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("the read snapshot", () => {
|
|
159
|
+
test("one REPEATABLE READ transaction, made read-only, with the server-side statement timeout, without parallel workers or JIT, before any read", async () => {
|
|
160
|
+
const database: FakeDatabase = useDatabase([typesRule([])]);
|
|
161
|
+
|
|
162
|
+
await TopologyQueries.getServiceMap(scope());
|
|
163
|
+
|
|
164
|
+
expect(database.isolationLevels).toEqual(["REPEATABLE READ"]);
|
|
165
|
+
expect(PostgresStatementTimeoutMs).toBeGreaterThan(0);
|
|
166
|
+
expect(
|
|
167
|
+
database.statements.slice(0, 4).map((statement: RecordedStatement) => {
|
|
168
|
+
return statement.sql;
|
|
169
|
+
}),
|
|
170
|
+
).toEqual([
|
|
171
|
+
"SET TRANSACTION READ ONLY",
|
|
172
|
+
`SET LOCAL statement_timeout = ${Math.floor(PostgresStatementTimeoutMs)}`,
|
|
173
|
+
"SET LOCAL max_parallel_workers_per_gather = 0",
|
|
174
|
+
"SET LOCAL jit = off",
|
|
175
|
+
]);
|
|
176
|
+
expect(database.statements[4]!.sql).toContain("project_types");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("every endpoint's snapshot sets the statement timeout before its first read", async () => {
|
|
180
|
+
const runs: Array<() => Promise<unknown>> = [
|
|
181
|
+
() => {
|
|
182
|
+
return TopologyQueries.getInfrastructure(scope());
|
|
183
|
+
},
|
|
184
|
+
() => {
|
|
185
|
+
return TopologyQueries.getEntity({
|
|
186
|
+
...scope(),
|
|
187
|
+
entityKey: "svc-a",
|
|
188
|
+
entityType: null,
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
() => {
|
|
192
|
+
return TopologyQueries.getEntityAllTime({
|
|
193
|
+
projectId: PROJECT_ID,
|
|
194
|
+
entityKey: "svc-a",
|
|
195
|
+
entityType: null,
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
() => {
|
|
199
|
+
return TopologyQueries.getCollectionPage({
|
|
200
|
+
...scope(),
|
|
201
|
+
entityType: EntityType.IoTDevice,
|
|
202
|
+
includeInactive: false,
|
|
203
|
+
nameTerms: [],
|
|
204
|
+
cursor: null,
|
|
205
|
+
limit: 10,
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
() => {
|
|
209
|
+
return TopologyQueries.getCollectionSearch({
|
|
210
|
+
...scope(),
|
|
211
|
+
includeInactive: false,
|
|
212
|
+
types: [{ entityType: EntityType.IoTDevice, nameTerms: [] }],
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
for (const run of runs) {
|
|
217
|
+
const database: FakeDatabase = useDatabase([
|
|
218
|
+
typesRule([]),
|
|
219
|
+
["COUNT(*)", rows([{ total: 0, count: 0 }])],
|
|
220
|
+
['"InventoryItem" i', rows([])],
|
|
221
|
+
]);
|
|
222
|
+
await run();
|
|
223
|
+
const timeoutAt: number = database.statements.findIndex(
|
|
224
|
+
(statement: RecordedStatement): boolean => {
|
|
225
|
+
return statement.sql.startsWith("SET LOCAL statement_timeout");
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
const firstRead: number = database.statements.findIndex(
|
|
229
|
+
(statement: RecordedStatement): boolean => {
|
|
230
|
+
return !statement.sql.startsWith("SET ");
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
expect(timeoutAt).toBe(1);
|
|
234
|
+
expect(firstRead).toBeGreaterThan(timeoutAt);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test.each([
|
|
239
|
+
[30_000, "SET LOCAL statement_timeout = 30000"],
|
|
240
|
+
[1, "SET LOCAL statement_timeout = 1"],
|
|
241
|
+
[2500.9, "SET LOCAL statement_timeout = 2500"],
|
|
242
|
+
[0, null],
|
|
243
|
+
[-5, null],
|
|
244
|
+
[Number.NaN, null],
|
|
245
|
+
[Number.POSITIVE_INFINITY, null],
|
|
246
|
+
])(
|
|
247
|
+
"statementTimeoutSql(%p) = %p: an integer, or the connection's own when none is configured",
|
|
248
|
+
(timeoutMs: number, expected: string | null) => {
|
|
249
|
+
expect(statementTimeoutSql(timeoutMs)).toBe(expected);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
test("a failing statement rejects the request", async () => {
|
|
254
|
+
useDatabase([
|
|
255
|
+
[
|
|
256
|
+
"project_types",
|
|
257
|
+
(): Array<JSONObject> => {
|
|
258
|
+
throw new Error("canceling statement due to statement timeout");
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
]);
|
|
262
|
+
await expect(TopologyQueries.getInfrastructure(scope())).rejects.toThrow(
|
|
263
|
+
"statement timeout",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("every statement binds the authorized project as $1", async () => {
|
|
268
|
+
const database: FakeDatabase = useDatabase([
|
|
269
|
+
typesRule([EntityType.Service, EntityType.KubernetesPod]),
|
|
270
|
+
duplicatesRule(),
|
|
271
|
+
['GROUP BY i."entityType"', rows([])],
|
|
272
|
+
[
|
|
273
|
+
"candidates AS MATERIALIZED",
|
|
274
|
+
rows([{ key: "pod-1", type: "k8s.pod" }]),
|
|
275
|
+
],
|
|
276
|
+
['i."displayName" AS "name" FROM', rows([{ key: "svc-a", name: "A" }])],
|
|
277
|
+
["SELECT DISTINCT", rows([])],
|
|
278
|
+
]);
|
|
279
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
280
|
+
const statements: Array<RecordedStatement> = dataStatements(database);
|
|
281
|
+
expect(statements.length).toBeGreaterThanOrEqual(5);
|
|
282
|
+
for (const statement of statements) {
|
|
283
|
+
expect(statement.params[0]).toBe(PROJECT_ID.toString());
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("service map", () => {
|
|
289
|
+
test("a project without services reads nothing else", async () => {
|
|
290
|
+
const database: FakeDatabase = useDatabase([
|
|
291
|
+
typesRule([EntityType.Host]),
|
|
292
|
+
]);
|
|
293
|
+
const response: TopologyServiceMapResponseJSON =
|
|
294
|
+
await TopologyQueries.getServiceMap(scope());
|
|
295
|
+
expect(dataStatements(database)).toHaveLength(1);
|
|
296
|
+
expect(response).toMatchObject({
|
|
297
|
+
formatVersion: TOPOLOGY_API_FORMAT_VERSION,
|
|
298
|
+
rangeStart: RANGE_START.toISOString(),
|
|
299
|
+
entities: [],
|
|
300
|
+
dependencies: [],
|
|
301
|
+
runsOn: [],
|
|
302
|
+
entityTruncation: null,
|
|
303
|
+
dependencyTruncation: null,
|
|
304
|
+
});
|
|
305
|
+
expect(Number.isNaN(Date.parse(response.generatedAt))).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("decodes services, calls, callees and runs-on counts", async () => {
|
|
309
|
+
const database: FakeDatabase = useDatabase([
|
|
310
|
+
typesRule([
|
|
311
|
+
EntityType.Service,
|
|
312
|
+
EntityType.Database,
|
|
313
|
+
EntityType.KubernetesPod,
|
|
314
|
+
]),
|
|
315
|
+
duplicatesRule(),
|
|
316
|
+
[
|
|
317
|
+
'"entityType" <> $',
|
|
318
|
+
rows([
|
|
319
|
+
{
|
|
320
|
+
key: "db-1",
|
|
321
|
+
type: EntityType.Database,
|
|
322
|
+
name: "orders",
|
|
323
|
+
source: "discovered",
|
|
324
|
+
lastSeenAt: "1790000000000",
|
|
325
|
+
descriptiveAttributes: { "db.system.name": "postgresql" },
|
|
326
|
+
identifyingAttributes: null,
|
|
327
|
+
},
|
|
328
|
+
]),
|
|
329
|
+
],
|
|
330
|
+
[
|
|
331
|
+
"jsonb_object_agg",
|
|
332
|
+
rows([
|
|
333
|
+
{
|
|
334
|
+
key: "svc-a",
|
|
335
|
+
type: EntityType.Service,
|
|
336
|
+
name: "checkout",
|
|
337
|
+
source: null,
|
|
338
|
+
lastSeenAt: 1790000000123,
|
|
339
|
+
descriptiveAttributes: {
|
|
340
|
+
"telemetry.sdk.language": "nodejs",
|
|
341
|
+
sneaky: 42,
|
|
342
|
+
},
|
|
343
|
+
identifyingAttributes: {},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
key: "svc-b",
|
|
347
|
+
type: EntityType.Service,
|
|
348
|
+
name: null,
|
|
349
|
+
source: "manual",
|
|
350
|
+
lastSeenAt: null,
|
|
351
|
+
descriptiveAttributes: null,
|
|
352
|
+
identifyingAttributes: null,
|
|
353
|
+
},
|
|
354
|
+
]),
|
|
355
|
+
],
|
|
356
|
+
[
|
|
357
|
+
'r."callCount" AS "callCount"',
|
|
358
|
+
rows([
|
|
359
|
+
{
|
|
360
|
+
from: "svc-a",
|
|
361
|
+
to: "db-1",
|
|
362
|
+
callCount: 10,
|
|
363
|
+
errorCount: "2",
|
|
364
|
+
avgDurationMs: null,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
from: "svc-a",
|
|
368
|
+
to: "svc-b",
|
|
369
|
+
callCount: null,
|
|
370
|
+
errorCount: null,
|
|
371
|
+
avgDurationMs: 7,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
from: "svc-b",
|
|
375
|
+
to: "ghost",
|
|
376
|
+
callCount: 1,
|
|
377
|
+
errorCount: 0,
|
|
378
|
+
avgDurationMs: 3,
|
|
379
|
+
},
|
|
380
|
+
]),
|
|
381
|
+
],
|
|
382
|
+
[
|
|
383
|
+
'AS "target"',
|
|
384
|
+
rows([
|
|
385
|
+
{ service: "svc-b", target: "pod-2" },
|
|
386
|
+
{ service: "svc-a", target: "pod-1" },
|
|
387
|
+
{ service: "svc-a", target: "pod-2" },
|
|
388
|
+
{ service: "svc-a", target: "gone" },
|
|
389
|
+
]),
|
|
390
|
+
],
|
|
391
|
+
[
|
|
392
|
+
'"active" FROM "InventoryItem" i',
|
|
393
|
+
rows([
|
|
394
|
+
{ key: "pod-1", type: EntityType.KubernetesPod, active: true },
|
|
395
|
+
{ key: "pod-2", type: EntityType.KubernetesPod, active: false },
|
|
396
|
+
]),
|
|
397
|
+
],
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
const response: TopologyServiceMapResponseJSON =
|
|
401
|
+
await TopologyQueries.getServiceMap(scope());
|
|
402
|
+
|
|
403
|
+
expect(response.entities).toEqual([
|
|
404
|
+
{
|
|
405
|
+
key: "svc-a",
|
|
406
|
+
type: EntityType.Service,
|
|
407
|
+
name: "checkout",
|
|
408
|
+
source: "",
|
|
409
|
+
lastSeenAt: 1790000000123,
|
|
410
|
+
descriptiveAttributes: { "telemetry.sdk.language": "nodejs" },
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
key: "svc-b",
|
|
414
|
+
type: EntityType.Service,
|
|
415
|
+
name: null,
|
|
416
|
+
source: "manual",
|
|
417
|
+
lastSeenAt: null,
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
key: "db-1",
|
|
421
|
+
type: EntityType.Database,
|
|
422
|
+
name: "orders",
|
|
423
|
+
source: "discovered",
|
|
424
|
+
lastSeenAt: 1790000000000,
|
|
425
|
+
descriptiveAttributes: { "db.system.name": "postgresql" },
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
expect(response.dependencies).toEqual([
|
|
429
|
+
{
|
|
430
|
+
from: "svc-a",
|
|
431
|
+
to: "db-1",
|
|
432
|
+
callCount: 10,
|
|
433
|
+
errorCount: 2,
|
|
434
|
+
avgDurationMs: null,
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
from: "svc-a",
|
|
438
|
+
to: "svc-b",
|
|
439
|
+
callCount: null,
|
|
440
|
+
errorCount: null,
|
|
441
|
+
avgDurationMs: 7,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
from: "svc-b",
|
|
445
|
+
to: "ghost",
|
|
446
|
+
callCount: 1,
|
|
447
|
+
errorCount: 0,
|
|
448
|
+
avgDurationMs: 3,
|
|
449
|
+
},
|
|
450
|
+
]);
|
|
451
|
+
expect(response.runsOn).toEqual([
|
|
452
|
+
{
|
|
453
|
+
service: "svc-a",
|
|
454
|
+
type: EntityType.KubernetesPod,
|
|
455
|
+
active: 1,
|
|
456
|
+
total: 2,
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
service: "svc-b",
|
|
460
|
+
type: EntityType.KubernetesPod,
|
|
461
|
+
active: 0,
|
|
462
|
+
total: 1,
|
|
463
|
+
},
|
|
464
|
+
]);
|
|
465
|
+
|
|
466
|
+
const statements: Array<RecordedStatement> = dataStatements(database);
|
|
467
|
+
// Callees: the called keys that are not services, in code-unit order.
|
|
468
|
+
const callees: RecordedStatement = statements.find(
|
|
469
|
+
(statement: RecordedStatement): boolean => {
|
|
470
|
+
return statement.sql.includes('"entityType" <> $');
|
|
471
|
+
},
|
|
472
|
+
)!;
|
|
473
|
+
expect(callees.params).toContainEqual(["db-1", "ghost"]);
|
|
474
|
+
// Calls and placements are read for the listed services.
|
|
475
|
+
for (const pattern of ['r."callCount" AS "callCount"', 'AS "target"']) {
|
|
476
|
+
const statement: RecordedStatement = statements.find(
|
|
477
|
+
(candidate: RecordedStatement): boolean => {
|
|
478
|
+
return candidate.sql.includes(pattern);
|
|
479
|
+
},
|
|
480
|
+
)!;
|
|
481
|
+
expect(statement.params).toContainEqual(["svc-a", "svc-b"]);
|
|
482
|
+
expect(statement.params[1]).toBe(RANGE_START.toISOString());
|
|
483
|
+
}
|
|
484
|
+
// Targets are looked up once each, sorted.
|
|
485
|
+
const targets: RecordedStatement = statements.find(
|
|
486
|
+
(statement: RecordedStatement): boolean => {
|
|
487
|
+
return statement.sql.includes('"active" FROM "InventoryItem" i');
|
|
488
|
+
},
|
|
489
|
+
)!;
|
|
490
|
+
expect(targets.params).toContainEqual(["gone", "pod-1", "pod-2"]);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("the duplicated keys found up front reach every item statement", async () => {
|
|
494
|
+
const database: FakeDatabase = useDatabase([
|
|
495
|
+
typesRule([EntityType.Service, EntityType.Host]),
|
|
496
|
+
duplicatesRule(["twin-b", "twin-a"]),
|
|
497
|
+
['"entityType" <> $', rows([])],
|
|
498
|
+
["jsonb_object_agg", rows([{ key: "svc-a", type: "service" }])],
|
|
499
|
+
[
|
|
500
|
+
'r."callCount" AS "callCount"',
|
|
501
|
+
rows([{ from: "svc-a", to: "host-1" }]),
|
|
502
|
+
],
|
|
503
|
+
['AS "target"', rows([{ service: "svc-a", target: "host-1" }])],
|
|
504
|
+
['"active" FROM "InventoryItem" i', rows([])],
|
|
505
|
+
]);
|
|
506
|
+
await TopologyQueries.getServiceMap(scope());
|
|
507
|
+
const itemStatements: Array<RecordedStatement> = dataStatements(
|
|
508
|
+
database,
|
|
509
|
+
).filter((statement: RecordedStatement): boolean => {
|
|
510
|
+
return (
|
|
511
|
+
statement.sql.includes('FROM "InventoryItem" i') &&
|
|
512
|
+
!statement.sql.includes("project_types") &&
|
|
513
|
+
!statement.sql.includes("HAVING")
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
expect(itemStatements).toHaveLength(3);
|
|
517
|
+
for (const statement of itemStatements) {
|
|
518
|
+
expect(statement.sql).toContain(
|
|
519
|
+
'NOT EXISTS (SELECT 1 FROM "InventoryItem" dup',
|
|
520
|
+
);
|
|
521
|
+
expect(statement.params).toContainEqual(["twin-a", "twin-b"]);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("over the caps: the first rows and exact totals", async () => {
|
|
526
|
+
const entityCap: number = TopologyApiLimits.MaxServiceMapEntities;
|
|
527
|
+
const dependencyCap: number = TopologyApiLimits.MaxServiceMapDependencies;
|
|
528
|
+
TopologyApiLimits.MaxServiceMapEntities = 2;
|
|
529
|
+
TopologyApiLimits.MaxServiceMapDependencies = 1;
|
|
530
|
+
try {
|
|
531
|
+
const database: FakeDatabase = useDatabase([
|
|
532
|
+
typesRule([EntityType.Service]),
|
|
533
|
+
duplicatesRule(),
|
|
534
|
+
[
|
|
535
|
+
'SELECT COUNT(*)::int AS "total" FROM "InventoryItemRelationship"',
|
|
536
|
+
rows([{ total: "40" }]),
|
|
537
|
+
],
|
|
538
|
+
[
|
|
539
|
+
'SELECT COUNT(*)::int AS "total" FROM "InventoryItem"',
|
|
540
|
+
rows([{ total: 9 }]),
|
|
541
|
+
],
|
|
542
|
+
[
|
|
543
|
+
"jsonb_object_agg",
|
|
544
|
+
rows([
|
|
545
|
+
{ key: "svc-a", type: "service" },
|
|
546
|
+
{ key: "svc-b", type: "service" },
|
|
547
|
+
{ key: "svc-c", type: "service" },
|
|
548
|
+
]),
|
|
549
|
+
],
|
|
550
|
+
[
|
|
551
|
+
'r."callCount" AS "callCount"',
|
|
552
|
+
rows([
|
|
553
|
+
{ from: "svc-a", to: "svc-b" },
|
|
554
|
+
{ from: "svc-a", to: "svc-z" },
|
|
555
|
+
]),
|
|
556
|
+
],
|
|
557
|
+
['AS "target"', rows([])],
|
|
558
|
+
]);
|
|
559
|
+
|
|
560
|
+
const response: TopologyServiceMapResponseJSON =
|
|
561
|
+
await TopologyQueries.getServiceMap(scope());
|
|
562
|
+
|
|
563
|
+
expect(
|
|
564
|
+
response.entities.map(
|
|
565
|
+
(entity: TopologyServiceMapEntityJSON): string => {
|
|
566
|
+
return entity.key;
|
|
567
|
+
},
|
|
568
|
+
),
|
|
569
|
+
).toEqual(["svc-a", "svc-b"]);
|
|
570
|
+
expect(response.entityTruncation).toEqual({ shown: 2, total: 9 });
|
|
571
|
+
expect(response.dependencies).toHaveLength(1);
|
|
572
|
+
expect(response.dependencyTruncation).toEqual({ shown: 1, total: 40 });
|
|
573
|
+
// Row statements ask for one more than the cap.
|
|
574
|
+
const serviceRows: RecordedStatement = dataStatements(database).find(
|
|
575
|
+
(statement: RecordedStatement): boolean => {
|
|
576
|
+
return statement.sql.includes("jsonb_object_agg");
|
|
577
|
+
},
|
|
578
|
+
)!;
|
|
579
|
+
expect(serviceRows.params[serviceRows.params.length - 1]).toBe(3);
|
|
580
|
+
// Every call went to a listed service: no callee lookup was needed.
|
|
581
|
+
expect(
|
|
582
|
+
dataStatements(database).some((statement: RecordedStatement) => {
|
|
583
|
+
return statement.sql.includes('"entityType" <> $');
|
|
584
|
+
}),
|
|
585
|
+
).toBe(false);
|
|
586
|
+
} finally {
|
|
587
|
+
TopologyApiLimits.MaxServiceMapEntities = entityCap;
|
|
588
|
+
TopologyApiLimits.MaxServiceMapDependencies = dependencyCap;
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe("infrastructure", () => {
|
|
594
|
+
function nodeRow(
|
|
595
|
+
key: string,
|
|
596
|
+
type: string,
|
|
597
|
+
extra: JSONObject = {},
|
|
598
|
+
): JSONObject {
|
|
599
|
+
return {
|
|
600
|
+
key,
|
|
601
|
+
type,
|
|
602
|
+
name: key,
|
|
603
|
+
source: "discovered",
|
|
604
|
+
lastSeenAt: 1790000000000,
|
|
605
|
+
active: true,
|
|
606
|
+
parent: null,
|
|
607
|
+
parentVia: null,
|
|
608
|
+
parentInactive: false,
|
|
609
|
+
activeParent: null,
|
|
610
|
+
activeParentVia: null,
|
|
611
|
+
...extra,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
test("collections, nodes with container indexes, services, placements and totals", async () => {
|
|
616
|
+
const database: FakeDatabase = useDatabase([
|
|
617
|
+
typesRule([
|
|
618
|
+
EntityType.Service,
|
|
619
|
+
EntityType.ServiceInstance,
|
|
620
|
+
EntityType.KubernetesPod,
|
|
621
|
+
EntityType.KubernetesNamespace,
|
|
622
|
+
EntityType.NetworkDevice,
|
|
623
|
+
EntityType.IoTDevice,
|
|
624
|
+
]),
|
|
625
|
+
duplicatesRule(),
|
|
626
|
+
[
|
|
627
|
+
'GROUP BY i."entityType"',
|
|
628
|
+
rows([
|
|
629
|
+
{
|
|
630
|
+
type: EntityType.IoTDevice,
|
|
631
|
+
total: TopologyApiLimits.InlineFlatItemsPerType,
|
|
632
|
+
active: 3,
|
|
633
|
+
lastSeenAt: 1,
|
|
634
|
+
activeLastSeenAt: 1,
|
|
635
|
+
},
|
|
636
|
+
{ type: EntityType.KubernetesNamespace, total: 2, active: 1 },
|
|
637
|
+
{
|
|
638
|
+
type: EntityType.KubernetesPod,
|
|
639
|
+
total: TopologyApiLimits.InlineFlatItemsPerType + 50,
|
|
640
|
+
active: 2,
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
type: EntityType.NetworkDevice,
|
|
644
|
+
total: String(TopologyApiLimits.InlineFlatItemsPerType + 1),
|
|
645
|
+
active: "7",
|
|
646
|
+
lastSeenAt: 1790000000999,
|
|
647
|
+
activeLastSeenAt: null,
|
|
648
|
+
},
|
|
649
|
+
]),
|
|
650
|
+
],
|
|
651
|
+
[
|
|
652
|
+
"candidates AS MATERIALIZED",
|
|
653
|
+
rows([
|
|
654
|
+
nodeRow("ns-a", EntityType.KubernetesNamespace),
|
|
655
|
+
nodeRow("ns-old", EntityType.KubernetesNamespace, {
|
|
656
|
+
active: false,
|
|
657
|
+
}),
|
|
658
|
+
nodeRow("pod-1", EntityType.KubernetesPod, {
|
|
659
|
+
parent: "ns-a",
|
|
660
|
+
parentVia: "part-of",
|
|
661
|
+
}),
|
|
662
|
+
nodeRow("pod-2", EntityType.KubernetesPod, {
|
|
663
|
+
parent: "ns-old",
|
|
664
|
+
parentVia: "part-of",
|
|
665
|
+
parentInactive: true,
|
|
666
|
+
activeParent: "ns-a",
|
|
667
|
+
activeParentVia: "member-of",
|
|
668
|
+
}),
|
|
669
|
+
nodeRow("pod-3", EntityType.KubernetesPod, {
|
|
670
|
+
parent: "ns-old",
|
|
671
|
+
parentVia: "part-of",
|
|
672
|
+
parentInactive: "t",
|
|
673
|
+
activeParent: null,
|
|
674
|
+
}),
|
|
675
|
+
nodeRow("pod-4", EntityType.KubernetesPod, {
|
|
676
|
+
parent: "not-shipped",
|
|
677
|
+
parentVia: "part-of",
|
|
678
|
+
}),
|
|
679
|
+
]),
|
|
680
|
+
],
|
|
681
|
+
[
|
|
682
|
+
'i."displayName" AS "name" FROM',
|
|
683
|
+
rows([
|
|
684
|
+
{ key: "svc-a", name: "A" },
|
|
685
|
+
{ key: "svc-b", name: null },
|
|
686
|
+
]),
|
|
687
|
+
],
|
|
688
|
+
[
|
|
689
|
+
'AS "target"',
|
|
690
|
+
rows([
|
|
691
|
+
{ service: "svc-b", target: "pod-1" },
|
|
692
|
+
{ service: "svc-a", target: "pod-2" },
|
|
693
|
+
{ service: "svc-a", target: "pod-1" },
|
|
694
|
+
{ service: "svc-a", target: "switch-1" },
|
|
695
|
+
{ service: "svc-z", target: "pod-1" },
|
|
696
|
+
]),
|
|
697
|
+
],
|
|
698
|
+
[
|
|
699
|
+
'r."callCount" AS "callCount"',
|
|
700
|
+
rows([
|
|
701
|
+
{
|
|
702
|
+
from: "svc-b",
|
|
703
|
+
to: "svc-a",
|
|
704
|
+
callCount: "12",
|
|
705
|
+
errorCount: 0,
|
|
706
|
+
avgDurationMs: null,
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
from: "svc-a",
|
|
710
|
+
to: "svc-b",
|
|
711
|
+
callCount: 1200,
|
|
712
|
+
errorCount: "6",
|
|
713
|
+
avgDurationMs: "45.5",
|
|
714
|
+
},
|
|
715
|
+
]),
|
|
716
|
+
],
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
720
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
721
|
+
|
|
722
|
+
expect(response.collections).toEqual([
|
|
723
|
+
{
|
|
724
|
+
type: EntityType.NetworkDevice,
|
|
725
|
+
total: TopologyApiLimits.InlineFlatItemsPerType + 1,
|
|
726
|
+
active: 7,
|
|
727
|
+
lastSeenAt: 1790000000999,
|
|
728
|
+
activeLastSeenAt: null,
|
|
729
|
+
},
|
|
730
|
+
]);
|
|
731
|
+
expect(response.nodes).toEqual([
|
|
732
|
+
{
|
|
733
|
+
key: "ns-a",
|
|
734
|
+
type: EntityType.KubernetesNamespace,
|
|
735
|
+
name: "ns-a",
|
|
736
|
+
source: "discovered",
|
|
737
|
+
lastSeenAt: 1790000000000,
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
key: "ns-old",
|
|
741
|
+
type: EntityType.KubernetesNamespace,
|
|
742
|
+
name: "ns-old",
|
|
743
|
+
source: "discovered",
|
|
744
|
+
lastSeenAt: 1790000000000,
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
key: "pod-1",
|
|
748
|
+
type: EntityType.KubernetesPod,
|
|
749
|
+
name: "pod-1",
|
|
750
|
+
source: "discovered",
|
|
751
|
+
lastSeenAt: 1790000000000,
|
|
752
|
+
parent: 0,
|
|
753
|
+
parentVia: "part-of",
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
key: "pod-2",
|
|
757
|
+
type: EntityType.KubernetesPod,
|
|
758
|
+
name: "pod-2",
|
|
759
|
+
source: "discovered",
|
|
760
|
+
lastSeenAt: 1790000000000,
|
|
761
|
+
parent: 1,
|
|
762
|
+
parentVia: "part-of",
|
|
763
|
+
activeParent: 0,
|
|
764
|
+
activeParentVia: "member-of",
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
key: "pod-3",
|
|
768
|
+
type: EntityType.KubernetesPod,
|
|
769
|
+
name: "pod-3",
|
|
770
|
+
source: "discovered",
|
|
771
|
+
lastSeenAt: 1790000000000,
|
|
772
|
+
parent: 1,
|
|
773
|
+
parentVia: "part-of",
|
|
774
|
+
activeParent: -1,
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
key: "pod-4",
|
|
778
|
+
type: EntityType.KubernetesPod,
|
|
779
|
+
name: "pod-4",
|
|
780
|
+
source: "discovered",
|
|
781
|
+
lastSeenAt: 1790000000000,
|
|
782
|
+
},
|
|
783
|
+
]);
|
|
784
|
+
expect(response.services).toEqual([
|
|
785
|
+
{ key: "svc-a", name: "A" },
|
|
786
|
+
{ key: "svc-b", name: null },
|
|
787
|
+
]);
|
|
788
|
+
// Onto shipped nodes only, from listed services only, sorted.
|
|
789
|
+
expect(response.placements).toEqual([
|
|
790
|
+
[0, 2],
|
|
791
|
+
[0, 3],
|
|
792
|
+
[1, 2],
|
|
793
|
+
]);
|
|
794
|
+
// Calls between the placed services, by index, sorted by (from, to).
|
|
795
|
+
expect(response.dependencies).toEqual([
|
|
796
|
+
{
|
|
797
|
+
from: 0,
|
|
798
|
+
to: 1,
|
|
799
|
+
callCount: 1200,
|
|
800
|
+
errorCount: 6,
|
|
801
|
+
avgDurationMs: 45.5,
|
|
802
|
+
},
|
|
803
|
+
{ from: 1, to: 0, callCount: 12, errorCount: 0, avgDurationMs: null },
|
|
804
|
+
]);
|
|
805
|
+
expect(response.dependencyTruncation).toBeNull();
|
|
806
|
+
const calls: RecordedStatement = dataStatements(database).find(
|
|
807
|
+
(statement: RecordedStatement): boolean => {
|
|
808
|
+
return statement.sql.includes('r."callCount" AS "callCount"');
|
|
809
|
+
},
|
|
810
|
+
)!;
|
|
811
|
+
// Callers and callees are both the placed services, in key order.
|
|
812
|
+
expect(calls.params[3]).toEqual(["svc-a", "svc-b"]);
|
|
813
|
+
expect(calls.params[4]).toEqual(["svc-a", "svc-b"]);
|
|
814
|
+
expect(calls.params[calls.params.length - 1]).toBe(
|
|
815
|
+
TopologyApiLimits.MaxServiceMapDependencies + 1,
|
|
816
|
+
);
|
|
817
|
+
expect(response.totals).toEqual({
|
|
818
|
+
resources: 6 + TopologyApiLimits.InlineFlatItemsPerType + 1,
|
|
819
|
+
activeResources: 5 + 7,
|
|
820
|
+
});
|
|
821
|
+
expect(response.truncation).toBeNull();
|
|
822
|
+
|
|
823
|
+
// Only infrastructure types are counted; only uncollected ones listed.
|
|
824
|
+
const counts: RecordedStatement = dataStatements(database).find(
|
|
825
|
+
(statement: RecordedStatement): boolean => {
|
|
826
|
+
return statement.sql.includes('GROUP BY i."entityType"');
|
|
827
|
+
},
|
|
828
|
+
)!;
|
|
829
|
+
expect(counts.params).toContainEqual([
|
|
830
|
+
EntityType.KubernetesPod,
|
|
831
|
+
EntityType.KubernetesNamespace,
|
|
832
|
+
EntityType.NetworkDevice,
|
|
833
|
+
EntityType.IoTDevice,
|
|
834
|
+
]);
|
|
835
|
+
const nodes: RecordedStatement = dataStatements(database).find(
|
|
836
|
+
(statement: RecordedStatement): boolean => {
|
|
837
|
+
return statement.sql.includes("candidates AS MATERIALIZED");
|
|
838
|
+
},
|
|
839
|
+
)!;
|
|
840
|
+
expect(nodes.params).toContainEqual([
|
|
841
|
+
EntityType.KubernetesPod,
|
|
842
|
+
EntityType.KubernetesNamespace,
|
|
843
|
+
EntityType.IoTDevice,
|
|
844
|
+
]);
|
|
845
|
+
expect(nodes.params).toContainEqual(NESTING_RELATIONSHIP_RANKS.ranks);
|
|
846
|
+
expect(nodes.params).toContainEqual(CONTAINER_SPECIFICITY_RANKS.ranks);
|
|
847
|
+
expect(nodes.params[nodes.params.length - 1]).toBe(
|
|
848
|
+
TopologyApiLimits.MaxInfrastructureNodes + 1,
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("over the node cap: exact totals from a count, truncation reported", async () => {
|
|
853
|
+
const cap: number = TopologyApiLimits.MaxInfrastructureNodes;
|
|
854
|
+
TopologyApiLimits.MaxInfrastructureNodes = 2;
|
|
855
|
+
try {
|
|
856
|
+
useDatabase([
|
|
857
|
+
typesRule([EntityType.Host]),
|
|
858
|
+
duplicatesRule(),
|
|
859
|
+
[
|
|
860
|
+
'GROUP BY i."entityType"',
|
|
861
|
+
rows([{ type: "host", total: 5, active: 3 }]),
|
|
862
|
+
],
|
|
863
|
+
[
|
|
864
|
+
"candidates AS MATERIALIZED",
|
|
865
|
+
rows([
|
|
866
|
+
nodeRow("h-1", EntityType.Host),
|
|
867
|
+
nodeRow("h-2", EntityType.Host, { parent: "h-3" }),
|
|
868
|
+
nodeRow("h-3", EntityType.Host),
|
|
869
|
+
]),
|
|
870
|
+
],
|
|
871
|
+
[
|
|
872
|
+
'COUNT(*) FILTER (WHERE n."active")',
|
|
873
|
+
rows([{ total: "5", active: "3" }]),
|
|
874
|
+
],
|
|
875
|
+
]);
|
|
876
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
877
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
878
|
+
expect(
|
|
879
|
+
response.nodes.map((node: TopologyInfrastructureNodeJSON): string => {
|
|
880
|
+
return node.key;
|
|
881
|
+
}),
|
|
882
|
+
).toEqual(["h-1", "h-2"]);
|
|
883
|
+
// h-2's container was not shipped, so it has none.
|
|
884
|
+
expect(response.nodes[1]!.parent).toBeUndefined();
|
|
885
|
+
expect(response.truncation).toEqual({ shown: 2, total: 5 });
|
|
886
|
+
expect(response.totals).toEqual({ resources: 5, activeResources: 3 });
|
|
887
|
+
expect(response.services).toEqual([]);
|
|
888
|
+
} finally {
|
|
889
|
+
TopologyApiLimits.MaxInfrastructureNodes = cap;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
/*
|
|
894
|
+
* Issue #3972: the map draws the traffic between resources from the calls
|
|
895
|
+
* between the services placed on them.
|
|
896
|
+
*/
|
|
897
|
+
describe("calls between placed services", () => {
|
|
898
|
+
function tracedDatabase(
|
|
899
|
+
placements: Array<JSONObject>,
|
|
900
|
+
calls: Array<JSONObject>,
|
|
901
|
+
extra: Array<[string, Responder]> = [],
|
|
902
|
+
): FakeDatabase {
|
|
903
|
+
return useDatabase([
|
|
904
|
+
typesRule([EntityType.Service, EntityType.KubernetesPod]),
|
|
905
|
+
duplicatesRule(),
|
|
906
|
+
...extra,
|
|
907
|
+
[
|
|
908
|
+
'GROUP BY i."entityType"',
|
|
909
|
+
rows([{ type: EntityType.KubernetesPod, total: 3, active: 3 }]),
|
|
910
|
+
],
|
|
911
|
+
[
|
|
912
|
+
"candidates AS MATERIALIZED",
|
|
913
|
+
rows([
|
|
914
|
+
nodeRow("pod-a", EntityType.KubernetesPod),
|
|
915
|
+
nodeRow("pod-b", EntityType.KubernetesPod),
|
|
916
|
+
nodeRow("pod-c", EntityType.KubernetesPod),
|
|
917
|
+
]),
|
|
918
|
+
],
|
|
919
|
+
[
|
|
920
|
+
'i."displayName" AS "name" FROM',
|
|
921
|
+
rows([
|
|
922
|
+
{ key: "svc-a", name: "a" },
|
|
923
|
+
{ key: "svc-b", name: "b" },
|
|
924
|
+
{ key: "svc-c", name: "c" },
|
|
925
|
+
{ key: "svc-idle", name: "idle" },
|
|
926
|
+
]),
|
|
927
|
+
],
|
|
928
|
+
['AS "target"', rows(placements)],
|
|
929
|
+
['r."callCount" AS "callCount"', rows(calls)],
|
|
930
|
+
]);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function callStatements(
|
|
934
|
+
database: FakeDatabase,
|
|
935
|
+
): Array<RecordedStatement> {
|
|
936
|
+
return dataStatements(database).filter(
|
|
937
|
+
(statement: RecordedStatement): boolean => {
|
|
938
|
+
return (
|
|
939
|
+
statement.sql.includes(
|
|
940
|
+
'FROM "InventoryItemRelationship" r WHERE',
|
|
941
|
+
) && statement.sql.includes('r."toEntityKey" = ANY(')
|
|
942
|
+
);
|
|
943
|
+
},
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
test("only services placed on a shipped node are asked about, as callers and callees", async () => {
|
|
948
|
+
const database: FakeDatabase = tracedDatabase(
|
|
949
|
+
[
|
|
950
|
+
{ service: "svc-c", target: "pod-c" },
|
|
951
|
+
{ service: "svc-a", target: "pod-a" },
|
|
952
|
+
{ service: "svc-a", target: "pod-b" },
|
|
953
|
+
/* Placed only on something that was not shipped. */
|
|
954
|
+
{ service: "svc-b", target: "switch-9" },
|
|
955
|
+
],
|
|
956
|
+
[{ from: "svc-a", to: "svc-c", callCount: 3 }],
|
|
957
|
+
);
|
|
958
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
959
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
960
|
+
const [statement] = callStatements(database);
|
|
961
|
+
expect(statement!.params[3]).toEqual(["svc-a", "svc-c"]);
|
|
962
|
+
expect(statement!.params[4]).toEqual(["svc-a", "svc-c"]);
|
|
963
|
+
expect(response.dependencies).toEqual([
|
|
964
|
+
{
|
|
965
|
+
from: 0,
|
|
966
|
+
to: 2,
|
|
967
|
+
callCount: 3,
|
|
968
|
+
errorCount: null,
|
|
969
|
+
avgDurationMs: null,
|
|
970
|
+
},
|
|
971
|
+
]);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("fewer than two placed services cannot call each other: nothing is read", async () => {
|
|
975
|
+
const database: FakeDatabase = tracedDatabase(
|
|
976
|
+
[
|
|
977
|
+
{ service: "svc-a", target: "pod-a" },
|
|
978
|
+
{ service: "svc-a", target: "pod-b" },
|
|
979
|
+
],
|
|
980
|
+
[],
|
|
981
|
+
);
|
|
982
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
983
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
984
|
+
expect(callStatements(database)).toEqual([]);
|
|
985
|
+
expect(response.dependencies).toEqual([]);
|
|
986
|
+
expect(response.dependencyTruncation).toBeNull();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("rows naming an unknown service or calling themselves are dropped", async () => {
|
|
990
|
+
tracedDatabase(
|
|
991
|
+
[
|
|
992
|
+
{ service: "svc-a", target: "pod-a" },
|
|
993
|
+
{ service: "svc-b", target: "pod-b" },
|
|
994
|
+
],
|
|
995
|
+
[
|
|
996
|
+
{ from: "svc-a", to: "svc-a", callCount: 1 },
|
|
997
|
+
{ from: "svc-a", to: "svc-gone", callCount: 1 },
|
|
998
|
+
{ from: "svc-gone", to: "svc-b", callCount: 1 },
|
|
999
|
+
{ from: "svc-b", to: "svc-a", callCount: 2, errorCount: 1 },
|
|
1000
|
+
],
|
|
1001
|
+
);
|
|
1002
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
1003
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
1004
|
+
expect(response.dependencies).toEqual([
|
|
1005
|
+
{ from: 1, to: 0, callCount: 2, errorCount: 1, avgDurationMs: null },
|
|
1006
|
+
]);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test("over the call cap: the first calls and the exact total", async () => {
|
|
1010
|
+
const cap: number = TopologyApiLimits.MaxServiceMapDependencies;
|
|
1011
|
+
TopologyApiLimits.MaxServiceMapDependencies = 1;
|
|
1012
|
+
try {
|
|
1013
|
+
const database: FakeDatabase = tracedDatabase(
|
|
1014
|
+
[
|
|
1015
|
+
{ service: "svc-a", target: "pod-a" },
|
|
1016
|
+
{ service: "svc-b", target: "pod-b" },
|
|
1017
|
+
{ service: "svc-c", target: "pod-c" },
|
|
1018
|
+
],
|
|
1019
|
+
[
|
|
1020
|
+
{ from: "svc-c", to: "svc-a", callCount: 9 },
|
|
1021
|
+
{ from: "svc-a", to: "svc-b", callCount: 5 },
|
|
1022
|
+
],
|
|
1023
|
+
[
|
|
1024
|
+
[
|
|
1025
|
+
'SELECT COUNT(*)::int AS "total" FROM "InventoryItemRelationship"',
|
|
1026
|
+
rows([{ total: "17" }]),
|
|
1027
|
+
],
|
|
1028
|
+
],
|
|
1029
|
+
);
|
|
1030
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
1031
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
1032
|
+
/* The first row in the Service Map's order is the one kept. */
|
|
1033
|
+
expect(response.dependencies).toEqual([
|
|
1034
|
+
{
|
|
1035
|
+
from: 2,
|
|
1036
|
+
to: 0,
|
|
1037
|
+
callCount: 9,
|
|
1038
|
+
errorCount: null,
|
|
1039
|
+
avgDurationMs: null,
|
|
1040
|
+
},
|
|
1041
|
+
]);
|
|
1042
|
+
expect(response.dependencyTruncation).toEqual({
|
|
1043
|
+
shown: 1,
|
|
1044
|
+
total: 17,
|
|
1045
|
+
});
|
|
1046
|
+
const counted: RecordedStatement = dataStatements(database).find(
|
|
1047
|
+
(statement: RecordedStatement): boolean => {
|
|
1048
|
+
return statement.sql.startsWith(
|
|
1049
|
+
'SELECT COUNT(*)::int AS "total" FROM "InventoryItemRelationship"',
|
|
1050
|
+
);
|
|
1051
|
+
},
|
|
1052
|
+
)!;
|
|
1053
|
+
expect(counted.params[4]).toEqual(["svc-a", "svc-b", "svc-c"]);
|
|
1054
|
+
} finally {
|
|
1055
|
+
TopologyApiLimits.MaxServiceMapDependencies = cap;
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("only collections and no services: no node, service or duplicate reads", async () => {
|
|
1061
|
+
const database: FakeDatabase = useDatabase([
|
|
1062
|
+
typesRule([EntityType.NetworkDevice]),
|
|
1063
|
+
[
|
|
1064
|
+
'GROUP BY i."entityType"',
|
|
1065
|
+
rows([{ type: EntityType.NetworkDevice, total: 79_600, active: 1 }]),
|
|
1066
|
+
],
|
|
1067
|
+
]);
|
|
1068
|
+
const response: TopologyInfrastructureResponseJSON =
|
|
1069
|
+
await TopologyQueries.getInfrastructure(scope());
|
|
1070
|
+
expect(dataStatements(database)).toHaveLength(2);
|
|
1071
|
+
expect(response.nodes).toEqual([]);
|
|
1072
|
+
expect(response.totals).toEqual({
|
|
1073
|
+
resources: 79_600,
|
|
1074
|
+
activeResources: 1,
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe("collections", () => {
|
|
1080
|
+
test("a page: total, items and the next cursor from the extra row", async () => {
|
|
1081
|
+
const database: FakeDatabase = useDatabase([
|
|
1082
|
+
['SELECT COUNT(*)::int AS "total"', rows([{ total: "3" }])],
|
|
1083
|
+
[
|
|
1084
|
+
"ORDER BY COALESCE",
|
|
1085
|
+
rows([
|
|
1086
|
+
{
|
|
1087
|
+
key: "k-1",
|
|
1088
|
+
type: "network.device",
|
|
1089
|
+
name: null,
|
|
1090
|
+
source: "discovered",
|
|
1091
|
+
lastSeenAt: null,
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
key: "k-2",
|
|
1095
|
+
type: "network.device",
|
|
1096
|
+
name: "b",
|
|
1097
|
+
source: "discovered",
|
|
1098
|
+
lastSeenAt: 5,
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
key: "k-3",
|
|
1102
|
+
type: "network.device",
|
|
1103
|
+
name: "c",
|
|
1104
|
+
source: "discovered",
|
|
1105
|
+
lastSeenAt: 6,
|
|
1106
|
+
},
|
|
1107
|
+
]),
|
|
1108
|
+
],
|
|
1109
|
+
]);
|
|
1110
|
+
const page: TopologyCollectionResponseJSON =
|
|
1111
|
+
await TopologyQueries.getCollectionPage({
|
|
1112
|
+
...scope(),
|
|
1113
|
+
entityType: EntityType.NetworkDevice,
|
|
1114
|
+
includeInactive: true,
|
|
1115
|
+
nameTerms: [],
|
|
1116
|
+
cursor: null,
|
|
1117
|
+
limit: 2,
|
|
1118
|
+
});
|
|
1119
|
+
expect(page.total).toBe(3);
|
|
1120
|
+
expect(
|
|
1121
|
+
page.items.map((item: TopologyEntityJSON): string => {
|
|
1122
|
+
return item.key;
|
|
1123
|
+
}),
|
|
1124
|
+
).toEqual(["k-1", "k-2"]);
|
|
1125
|
+
expect(page.nextCursor).toEqual({ name: "b", key: "k-2" });
|
|
1126
|
+
expect(page.entityType).toBe(EntityType.NetworkDevice);
|
|
1127
|
+
const pageStatement: RecordedStatement = dataStatements(database)[1]!;
|
|
1128
|
+
expect(pageStatement.params[pageStatement.params.length - 1]).toBe(3);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("an unnamed last row continues from the empty name", async () => {
|
|
1132
|
+
useDatabase([
|
|
1133
|
+
['SELECT COUNT(*)::int AS "total"', rows([{ total: 2 }])],
|
|
1134
|
+
[
|
|
1135
|
+
"ORDER BY COALESCE",
|
|
1136
|
+
rows([
|
|
1137
|
+
{ key: "k-1", type: "network.device", name: null },
|
|
1138
|
+
{ key: "k-2", type: "network.device", name: null },
|
|
1139
|
+
]),
|
|
1140
|
+
],
|
|
1141
|
+
]);
|
|
1142
|
+
const page: TopologyCollectionResponseJSON =
|
|
1143
|
+
await TopologyQueries.getCollectionPage({
|
|
1144
|
+
...scope(),
|
|
1145
|
+
entityType: EntityType.NetworkDevice,
|
|
1146
|
+
includeInactive: true,
|
|
1147
|
+
nameTerms: [],
|
|
1148
|
+
cursor: null,
|
|
1149
|
+
limit: 1,
|
|
1150
|
+
});
|
|
1151
|
+
expect(page.nextCursor).toEqual({ name: "", key: "k-1" });
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
test("the last page has no cursor", async () => {
|
|
1155
|
+
useDatabase([
|
|
1156
|
+
['SELECT COUNT(*)::int AS "total"', rows([{ total: 1 }])],
|
|
1157
|
+
["ORDER BY COALESCE", rows([{ key: "k-1", type: "network.device" }])],
|
|
1158
|
+
]);
|
|
1159
|
+
const page: TopologyCollectionResponseJSON =
|
|
1160
|
+
await TopologyQueries.getCollectionPage({
|
|
1161
|
+
...scope(),
|
|
1162
|
+
entityType: EntityType.NetworkDevice,
|
|
1163
|
+
includeInactive: false,
|
|
1164
|
+
nameTerms: ["core"],
|
|
1165
|
+
cursor: { name: "a", key: "k-0" },
|
|
1166
|
+
limit: 50,
|
|
1167
|
+
});
|
|
1168
|
+
expect(page.nextCursor).toBeNull();
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("search: no types, no read; otherwise matches in request order, zero counts dropped", async () => {
|
|
1172
|
+
const none: FakeDatabase = useDatabase([]);
|
|
1173
|
+
const empty: TopologyCollectionSearchResponseJSON =
|
|
1174
|
+
await TopologyQueries.getCollectionSearch({
|
|
1175
|
+
...scope(),
|
|
1176
|
+
includeInactive: true,
|
|
1177
|
+
types: [],
|
|
1178
|
+
});
|
|
1179
|
+
expect(empty.matches).toEqual([]);
|
|
1180
|
+
expect(none.statements).toEqual([]);
|
|
1181
|
+
|
|
1182
|
+
useDatabase([
|
|
1183
|
+
[
|
|
1184
|
+
"UNION ALL",
|
|
1185
|
+
rows([
|
|
1186
|
+
{ entityType: EntityType.NetworkDevice, count: "12" },
|
|
1187
|
+
{ entityType: EntityType.IoTDevice, count: 0 },
|
|
1188
|
+
{ entityType: EntityType.CloudResource, count: 4 },
|
|
1189
|
+
]),
|
|
1190
|
+
],
|
|
1191
|
+
]);
|
|
1192
|
+
const response: TopologyCollectionSearchResponseJSON =
|
|
1193
|
+
await TopologyQueries.getCollectionSearch({
|
|
1194
|
+
...scope(),
|
|
1195
|
+
includeInactive: true,
|
|
1196
|
+
types: [
|
|
1197
|
+
{ entityType: EntityType.CloudResource, nameTerms: ["a"] },
|
|
1198
|
+
{ entityType: EntityType.IoTDevice, nameTerms: ["a"] },
|
|
1199
|
+
{ entityType: EntityType.NetworkDevice, nameTerms: ["a"] },
|
|
1200
|
+
],
|
|
1201
|
+
});
|
|
1202
|
+
expect(response.matches).toEqual([
|
|
1203
|
+
{ entityType: EntityType.CloudResource, count: 4 },
|
|
1204
|
+
{ entityType: EntityType.NetworkDevice, count: 12 },
|
|
1205
|
+
]);
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
describe("the drawer", () => {
|
|
1210
|
+
const entityRow: JSONObject = {
|
|
1211
|
+
id: "0b9c0d4e-0000-4000-8000-000000000001",
|
|
1212
|
+
key: "svc-a",
|
|
1213
|
+
type: EntityType.Service,
|
|
1214
|
+
name: "checkout",
|
|
1215
|
+
source: "discovered",
|
|
1216
|
+
lastSeenAt: 1790000000000,
|
|
1217
|
+
firstSeenAt: 1780000000000,
|
|
1218
|
+
resourceType: "Service",
|
|
1219
|
+
resourceId: null,
|
|
1220
|
+
identifyingAttributes: { "service.name": "checkout" },
|
|
1221
|
+
descriptiveAttributes: [],
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
function sectionsRow(scanned: number): JSONObject {
|
|
1225
|
+
return {
|
|
1226
|
+
scanned,
|
|
1227
|
+
sections: [
|
|
1228
|
+
{ section: "calls", total: 3, unknownTotal: 1 },
|
|
1229
|
+
{ section: "runsOn", total: 1, unknownTotal: 0 },
|
|
1230
|
+
{ section: "bogus", total: 9, unknownTotal: 9 },
|
|
1231
|
+
],
|
|
1232
|
+
rows: [
|
|
1233
|
+
{
|
|
1234
|
+
section: "calls",
|
|
1235
|
+
relationshipType: "depends-on",
|
|
1236
|
+
direction: "out",
|
|
1237
|
+
otherKey: "db-1",
|
|
1238
|
+
otherKnown: true,
|
|
1239
|
+
otherName: "orders",
|
|
1240
|
+
otherType: "database",
|
|
1241
|
+
callCount: 10,
|
|
1242
|
+
errorCount: null,
|
|
1243
|
+
avgDurationMs: 2,
|
|
1244
|
+
lastSeenAt: 1790000000000,
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
section: "calls",
|
|
1248
|
+
relationshipType: "depends-on",
|
|
1249
|
+
direction: "out",
|
|
1250
|
+
otherKey: "ghost",
|
|
1251
|
+
otherKnown: false,
|
|
1252
|
+
otherName: null,
|
|
1253
|
+
otherType: null,
|
|
1254
|
+
callCount: null,
|
|
1255
|
+
errorCount: null,
|
|
1256
|
+
avgDurationMs: null,
|
|
1257
|
+
lastSeenAt: null,
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
section: "runsOn",
|
|
1261
|
+
relationshipType: "runs-on",
|
|
1262
|
+
direction: "out",
|
|
1263
|
+
otherKey: "pod-1",
|
|
1264
|
+
otherKnown: true,
|
|
1265
|
+
otherName: "pod",
|
|
1266
|
+
otherType: "k8s.pod",
|
|
1267
|
+
callCount: null,
|
|
1268
|
+
errorCount: null,
|
|
1269
|
+
avgDurationMs: null,
|
|
1270
|
+
lastSeenAt: 1,
|
|
1271
|
+
},
|
|
1272
|
+
],
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
test("the entity and its sections", async () => {
|
|
1277
|
+
const database: FakeDatabase = useDatabase([
|
|
1278
|
+
typesRule([EntityType.Service]),
|
|
1279
|
+
["LIMIT 1", rows([entityRow])],
|
|
1280
|
+
["scan_out AS MATERIALIZED", rows([sectionsRow(5)])],
|
|
1281
|
+
]);
|
|
1282
|
+
const response: TopologyEntityResponseJSON =
|
|
1283
|
+
await TopologyQueries.getEntity({
|
|
1284
|
+
...scope(),
|
|
1285
|
+
entityKey: "svc-a",
|
|
1286
|
+
entityType: null,
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
expect(response.entity).toEqual({
|
|
1290
|
+
id: "0b9c0d4e-0000-4000-8000-000000000001",
|
|
1291
|
+
key: "svc-a",
|
|
1292
|
+
type: EntityType.Service,
|
|
1293
|
+
name: "checkout",
|
|
1294
|
+
source: "discovered",
|
|
1295
|
+
lastSeenAt: 1790000000000,
|
|
1296
|
+
firstSeenAt: 1780000000000,
|
|
1297
|
+
resourceType: "Service",
|
|
1298
|
+
resourceId: null,
|
|
1299
|
+
identifyingAttributes: { "service.name": "checkout" },
|
|
1300
|
+
descriptiveAttributes: null,
|
|
1301
|
+
});
|
|
1302
|
+
expect(response.isScanLimited).toBe(false);
|
|
1303
|
+
expect(response.sections.calls.total).toBe(3);
|
|
1304
|
+
expect(response.sections.calls.unknownTotal).toBe(1);
|
|
1305
|
+
expect(response.sections.calls.rows).toHaveLength(2);
|
|
1306
|
+
expect(response.sections.calls.rows[0]).not.toHaveProperty("section");
|
|
1307
|
+
expect(response.sections.calls.nextOffset).toBe(2);
|
|
1308
|
+
expect(response.sections.runsOn.nextOffset).toBeNull();
|
|
1309
|
+
expect(response.sections.calledBy).toEqual({
|
|
1310
|
+
total: 0,
|
|
1311
|
+
unknownTotal: 0,
|
|
1312
|
+
rows: [],
|
|
1313
|
+
nextOffset: null,
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
const sections: RecordedStatement = dataStatements(database)[2]!;
|
|
1317
|
+
// A service: outbound runs-on / hosted-on are its runsOn section.
|
|
1318
|
+
expect(sections.params).toContain(true);
|
|
1319
|
+
expect(sections.params).toContain(
|
|
1320
|
+
TopologyApiLimits.EntityConnectionScanLimit + 1,
|
|
1321
|
+
);
|
|
1322
|
+
expect(sections.params).toContain(TopologyApiLimits.EntityDependencyRows);
|
|
1323
|
+
expect(sections.params).toContain(TopologyApiLimits.EntityOtherRows);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test("more relationships than the scan limit: totals are lower bounds", async () => {
|
|
1327
|
+
useDatabase([
|
|
1328
|
+
typesRule([EntityType.Service]),
|
|
1329
|
+
["LIMIT 1", rows([{ ...entityRow, type: EntityType.KubernetesNode }])],
|
|
1330
|
+
[
|
|
1331
|
+
"scan_out AS MATERIALIZED",
|
|
1332
|
+
rows([sectionsRow(TopologyApiLimits.EntityConnectionScanLimit + 1)]),
|
|
1333
|
+
],
|
|
1334
|
+
]);
|
|
1335
|
+
const response: TopologyEntityResponseJSON =
|
|
1336
|
+
await TopologyQueries.getEntity({
|
|
1337
|
+
...scope(),
|
|
1338
|
+
entityKey: "node-1",
|
|
1339
|
+
entityType: EntityType.KubernetesNode,
|
|
1340
|
+
});
|
|
1341
|
+
expect(response.isScanLimited).toBe(true);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
test("not found: no sections are read", async () => {
|
|
1345
|
+
const database: FakeDatabase = useDatabase([
|
|
1346
|
+
typesRule([EntityType.Service]),
|
|
1347
|
+
["LIMIT 1", rows([])],
|
|
1348
|
+
]);
|
|
1349
|
+
const response: TopologyEntityResponseJSON =
|
|
1350
|
+
await TopologyQueries.getEntity({
|
|
1351
|
+
...scope(),
|
|
1352
|
+
entityKey: "nope",
|
|
1353
|
+
entityType: "k8s.pod",
|
|
1354
|
+
});
|
|
1355
|
+
expect(response.entity).toBeNull();
|
|
1356
|
+
expect(response.sections.related.total).toBe(0);
|
|
1357
|
+
expect(dataStatements(database)).toHaveLength(2);
|
|
1358
|
+
expect(dataStatements(database)[1]!.params).toEqual([
|
|
1359
|
+
PROJECT_ID.toString(),
|
|
1360
|
+
[EntityType.Service],
|
|
1361
|
+
"nope",
|
|
1362
|
+
"k8s.pod",
|
|
1363
|
+
]);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("an empty project: nothing past the type list", async () => {
|
|
1367
|
+
const database: FakeDatabase = useDatabase([typesRule([])]);
|
|
1368
|
+
const response: TopologyEntityResponseJSON =
|
|
1369
|
+
await TopologyQueries.getEntity({
|
|
1370
|
+
...scope(),
|
|
1371
|
+
entityKey: "svc-a",
|
|
1372
|
+
entityType: null,
|
|
1373
|
+
});
|
|
1374
|
+
expect(response.entity).toBeNull();
|
|
1375
|
+
expect(dataStatements(database)).toHaveLength(1);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
test("show more: one section's page, offsets continue from the request's", async () => {
|
|
1379
|
+
const database: FakeDatabase = useDatabase([
|
|
1380
|
+
typesRule([EntityType.Service]),
|
|
1381
|
+
["LIMIT 1", rows([entityRow])],
|
|
1382
|
+
["scan_out AS MATERIALIZED", rows([sectionsRow(5)])],
|
|
1383
|
+
]);
|
|
1384
|
+
const response: TopologyEntityConnectionsResponseJSON =
|
|
1385
|
+
await TopologyQueries.getEntityConnections({
|
|
1386
|
+
...scope(),
|
|
1387
|
+
entityKey: "svc-a",
|
|
1388
|
+
entityType: null,
|
|
1389
|
+
section: "calls",
|
|
1390
|
+
offset: 0,
|
|
1391
|
+
limit: 2,
|
|
1392
|
+
});
|
|
1393
|
+
expect(response.section).toBe("calls");
|
|
1394
|
+
expect(response.connections.rows).toHaveLength(2);
|
|
1395
|
+
expect(response.connections.nextOffset).toBe(2);
|
|
1396
|
+
const sections: RecordedStatement = dataStatements(database)[2]!;
|
|
1397
|
+
expect(sections.params.slice(-3)).toEqual([0, "calls", 2]);
|
|
1398
|
+
|
|
1399
|
+
useDatabase([
|
|
1400
|
+
typesRule([EntityType.Service]),
|
|
1401
|
+
["LIMIT 1", rows([entityRow])],
|
|
1402
|
+
["scan_out AS MATERIALIZED", rows([sectionsRow(5)])],
|
|
1403
|
+
]);
|
|
1404
|
+
const later: TopologyEntityConnectionsResponseJSON =
|
|
1405
|
+
await TopologyQueries.getEntityConnections({
|
|
1406
|
+
...scope(),
|
|
1407
|
+
entityKey: "svc-a",
|
|
1408
|
+
entityType: null,
|
|
1409
|
+
section: "calls",
|
|
1410
|
+
offset: 1,
|
|
1411
|
+
limit: 2,
|
|
1412
|
+
});
|
|
1413
|
+
// 1 + 2 rows reaches the total of 3.
|
|
1414
|
+
expect(later.connections.nextOffset).toBeNull();
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
/*
|
|
1418
|
+
* The inventory item page: the drawer's reads with no range, archived
|
|
1419
|
+
* items found, and the other end's id on every row.
|
|
1420
|
+
*/
|
|
1421
|
+
describe("all time", () => {
|
|
1422
|
+
const entityRow: JSONObject = {
|
|
1423
|
+
id: "0b9c0d4e-0000-4000-8000-000000000002",
|
|
1424
|
+
key: "ns-a",
|
|
1425
|
+
type: EntityType.KubernetesNamespace,
|
|
1426
|
+
name: "payments",
|
|
1427
|
+
source: "discovered",
|
|
1428
|
+
lastSeenAt: null,
|
|
1429
|
+
firstSeenAt: null,
|
|
1430
|
+
resourceType: null,
|
|
1431
|
+
resourceId: null,
|
|
1432
|
+
identifyingAttributes: null,
|
|
1433
|
+
descriptiveAttributes: null,
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
const sectionsRow: JSONObject = {
|
|
1437
|
+
scanned: 3,
|
|
1438
|
+
sections: [{ section: "related", total: 3, unknownTotal: 1 }],
|
|
1439
|
+
rows: [
|
|
1440
|
+
{
|
|
1441
|
+
section: "related",
|
|
1442
|
+
relationshipType: "part-of",
|
|
1443
|
+
direction: "in",
|
|
1444
|
+
otherKey: "pod-1",
|
|
1445
|
+
otherKnown: true,
|
|
1446
|
+
otherId: "0b9c0d4e-0000-4000-8000-000000000003",
|
|
1447
|
+
otherName: "api-7d9f",
|
|
1448
|
+
otherType: "k8s.pod",
|
|
1449
|
+
callCount: null,
|
|
1450
|
+
errorCount: null,
|
|
1451
|
+
avgDurationMs: null,
|
|
1452
|
+
lastSeenAt: 1790000000000,
|
|
1453
|
+
},
|
|
1454
|
+
{
|
|
1455
|
+
section: "related",
|
|
1456
|
+
relationshipType: "part-of",
|
|
1457
|
+
direction: "out",
|
|
1458
|
+
otherKey: "gone",
|
|
1459
|
+
otherKnown: false,
|
|
1460
|
+
otherId: null,
|
|
1461
|
+
otherName: null,
|
|
1462
|
+
otherType: null,
|
|
1463
|
+
callCount: null,
|
|
1464
|
+
errorCount: null,
|
|
1465
|
+
avgDurationMs: null,
|
|
1466
|
+
lastSeenAt: null,
|
|
1467
|
+
},
|
|
1468
|
+
],
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
function allTimeDatabase(): FakeDatabase {
|
|
1472
|
+
return useDatabase([
|
|
1473
|
+
typesRule([EntityType.KubernetesNamespace, EntityType.KubernetesPod]),
|
|
1474
|
+
["LIMIT 1", rows([entityRow])],
|
|
1475
|
+
["scan_out AS MATERIALIZED", rows([sectionsRow])],
|
|
1476
|
+
]);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
test("the entity and its sections: no range read or echoed, other ends carry their ids", async () => {
|
|
1480
|
+
const database: FakeDatabase = allTimeDatabase();
|
|
1481
|
+
const response: TopologyEntityAllTimeResponseJSON =
|
|
1482
|
+
await TopologyQueries.getEntityAllTime({
|
|
1483
|
+
projectId: PROJECT_ID,
|
|
1484
|
+
entityKey: "ns-a",
|
|
1485
|
+
entityType: EntityType.KubernetesNamespace,
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
expect(response.formatVersion).toBe(TOPOLOGY_API_FORMAT_VERSION);
|
|
1489
|
+
expect(response).not.toHaveProperty("rangeStart");
|
|
1490
|
+
expect(Number.isNaN(Date.parse(response.generatedAt))).toBe(false);
|
|
1491
|
+
expect(response.entity).toMatchObject({
|
|
1492
|
+
id: "0b9c0d4e-0000-4000-8000-000000000002",
|
|
1493
|
+
key: "ns-a",
|
|
1494
|
+
});
|
|
1495
|
+
expect(response.isScanLimited).toBe(false);
|
|
1496
|
+
expect(response.sections.related.total).toBe(3);
|
|
1497
|
+
expect(response.sections.related.unknownTotal).toBe(1);
|
|
1498
|
+
expect(response.sections.related.nextOffset).toBe(2);
|
|
1499
|
+
expect(response.sections.related.rows[0]).toEqual({
|
|
1500
|
+
relationshipType: "part-of",
|
|
1501
|
+
direction: "in",
|
|
1502
|
+
otherKey: "pod-1",
|
|
1503
|
+
otherKnown: true,
|
|
1504
|
+
otherId: "0b9c0d4e-0000-4000-8000-000000000003",
|
|
1505
|
+
otherName: "api-7d9f",
|
|
1506
|
+
otherType: "k8s.pod",
|
|
1507
|
+
callCount: null,
|
|
1508
|
+
errorCount: null,
|
|
1509
|
+
avgDurationMs: null,
|
|
1510
|
+
lastSeenAt: 1790000000000,
|
|
1511
|
+
});
|
|
1512
|
+
expect(response.sections.related.rows[1]!.otherId).toBeNull();
|
|
1513
|
+
|
|
1514
|
+
const [types, entity, sections] = dataStatements(database);
|
|
1515
|
+
expect(types!.sql).toContain("project_types");
|
|
1516
|
+
// Archived items are found, so nothing filters them out.
|
|
1517
|
+
expect(entity!.sql).not.toContain(`"isArchived" = false`);
|
|
1518
|
+
expect(entity!.sql).toContain(`i."isArchived" ASC`);
|
|
1519
|
+
expect(sections!.sql).not.toContain(`"lastSeenAt" >=`);
|
|
1520
|
+
expect(sections!.sql).toContain(`o."id" AS "otherId"`);
|
|
1521
|
+
// Not a service: its outbound runs-on is not a runsOn section.
|
|
1522
|
+
expect(sections!.params).toContain(false);
|
|
1523
|
+
expect(sections!.params).toContain(
|
|
1524
|
+
TopologyApiLimits.EntityConnectionScanLimit + 1,
|
|
1525
|
+
);
|
|
1526
|
+
for (const statement of [types!, entity!, sections!]) {
|
|
1527
|
+
expect(statement.params[0]).toBe(PROJECT_ID.toString());
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
test("the drawer's rows never gain an id", async () => {
|
|
1532
|
+
const drawerRows: Array<JSONObject> = (
|
|
1533
|
+
sectionsRow["rows"] as Array<JSONObject>
|
|
1534
|
+
).map((row: JSONObject): JSONObject => {
|
|
1535
|
+
const rest: JSONObject = { ...row };
|
|
1536
|
+
delete rest["otherId"];
|
|
1537
|
+
return rest;
|
|
1538
|
+
});
|
|
1539
|
+
useDatabase([
|
|
1540
|
+
typesRule([EntityType.KubernetesNamespace]),
|
|
1541
|
+
["LIMIT 1", rows([entityRow])],
|
|
1542
|
+
[
|
|
1543
|
+
"scan_out AS MATERIALIZED",
|
|
1544
|
+
(sql: string): Array<JSONObject> => {
|
|
1545
|
+
expect(sql).not.toContain("otherId");
|
|
1546
|
+
return [{ ...sectionsRow, rows: drawerRows }];
|
|
1547
|
+
},
|
|
1548
|
+
],
|
|
1549
|
+
]);
|
|
1550
|
+
const response: TopologyEntityResponseJSON =
|
|
1551
|
+
await TopologyQueries.getEntity({
|
|
1552
|
+
...scope(),
|
|
1553
|
+
entityKey: "ns-a",
|
|
1554
|
+
entityType: null,
|
|
1555
|
+
});
|
|
1556
|
+
expect(response.rangeStart).toBe(RANGE_START.toISOString());
|
|
1557
|
+
expect(response.sections.related.rows).toHaveLength(2);
|
|
1558
|
+
for (const row of response.sections.related.rows) {
|
|
1559
|
+
expect(row).not.toHaveProperty("otherId");
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
test("not found: no sections are read", async () => {
|
|
1564
|
+
const database: FakeDatabase = useDatabase([
|
|
1565
|
+
typesRule([EntityType.Service]),
|
|
1566
|
+
["LIMIT 1", rows([])],
|
|
1567
|
+
]);
|
|
1568
|
+
const response: TopologyEntityAllTimeResponseJSON =
|
|
1569
|
+
await TopologyQueries.getEntityAllTime({
|
|
1570
|
+
projectId: PROJECT_ID,
|
|
1571
|
+
entityKey: "nope",
|
|
1572
|
+
entityType: null,
|
|
1573
|
+
});
|
|
1574
|
+
expect(response.entity).toBeNull();
|
|
1575
|
+
expect(response.sections.related).toEqual({
|
|
1576
|
+
total: 0,
|
|
1577
|
+
unknownTotal: 0,
|
|
1578
|
+
rows: [],
|
|
1579
|
+
nextOffset: null,
|
|
1580
|
+
});
|
|
1581
|
+
expect(dataStatements(database)).toHaveLength(2);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
test("show more: one section's page, from the request's offset, with no range", async () => {
|
|
1585
|
+
const database: FakeDatabase = allTimeDatabase();
|
|
1586
|
+
const response: TopologyEntityAllTimeConnectionsResponseJSON =
|
|
1587
|
+
await TopologyQueries.getEntityAllTimeConnections({
|
|
1588
|
+
projectId: PROJECT_ID,
|
|
1589
|
+
entityKey: "ns-a",
|
|
1590
|
+
entityType: null,
|
|
1591
|
+
section: "related",
|
|
1592
|
+
offset: 1,
|
|
1593
|
+
limit: 2,
|
|
1594
|
+
});
|
|
1595
|
+
expect(response).not.toHaveProperty("rangeStart");
|
|
1596
|
+
expect(response.section).toBe("related");
|
|
1597
|
+
expect(response.connections.total).toBe(3);
|
|
1598
|
+
expect(
|
|
1599
|
+
response.connections.rows.map(
|
|
1600
|
+
(row: TopologyConnectionRowJSON): string | null | undefined => {
|
|
1601
|
+
return row.otherId;
|
|
1602
|
+
},
|
|
1603
|
+
),
|
|
1604
|
+
).toEqual(["0b9c0d4e-0000-4000-8000-000000000003", null]);
|
|
1605
|
+
// 1 + 2 rows reaches the total of 3.
|
|
1606
|
+
expect(response.connections.nextOffset).toBeNull();
|
|
1607
|
+
const sections: RecordedStatement = dataStatements(database)[2]!;
|
|
1608
|
+
expect(sections.params.slice(-3)).toEqual([1, "related", 2]);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
test("more relationships than the scan limit: totals are lower bounds", async () => {
|
|
1612
|
+
useDatabase([
|
|
1613
|
+
typesRule([EntityType.KubernetesNamespace]),
|
|
1614
|
+
["LIMIT 1", rows([entityRow])],
|
|
1615
|
+
[
|
|
1616
|
+
"scan_out AS MATERIALIZED",
|
|
1617
|
+
rows([
|
|
1618
|
+
{
|
|
1619
|
+
...sectionsRow,
|
|
1620
|
+
scanned: TopologyApiLimits.EntityConnectionScanLimit + 1,
|
|
1621
|
+
},
|
|
1622
|
+
]),
|
|
1623
|
+
],
|
|
1624
|
+
]);
|
|
1625
|
+
const response: TopologyEntityAllTimeResponseJSON =
|
|
1626
|
+
await TopologyQueries.getEntityAllTime({
|
|
1627
|
+
projectId: PROJECT_ID,
|
|
1628
|
+
entityKey: "ns-a",
|
|
1629
|
+
entityType: null,
|
|
1630
|
+
});
|
|
1631
|
+
expect(response.isScanLimited).toBe(true);
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
});
|