@oneuptime/common 12.0.10 → 12.0.11
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/NetworkDevice.ts +44 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/NetworkDeviceLinkService.ts +338 -2
- package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
- package/Server/Types/Database/JSONColumnQuery.ts +42 -4
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
- package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
- package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
- package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
- package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
- package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
- package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
- package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
- package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
- package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
- package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
- package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
- package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
- package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
- package/Types/Billing/PayAsYouGoPricing.ts +47 -0
- package/Types/CustomField/CustomFieldType.ts +28 -0
- package/Types/Monitor/MonitorType.ts +11 -0
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
- package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
- package/UI/Components/Charts/Line/LineChart.tsx +4 -2
- package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.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/NetworkDeviceLinkService.js +253 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
- package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
- package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +10 -0
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,14 +1,33 @@
|
|
|
1
|
+
import CloudResource from "../../../../Models/DatabaseModels/CloudResource";
|
|
2
|
+
import BaseModel from "../../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
3
|
+
import DockerHost from "../../../../Models/DatabaseModels/DockerHost";
|
|
1
4
|
import InventoryItem from "../../../../Models/DatabaseModels/InventoryItem";
|
|
5
|
+
import IoTDevice from "../../../../Models/DatabaseModels/IoTDevice";
|
|
6
|
+
import NetworkDevice from "../../../../Models/DatabaseModels/NetworkDevice";
|
|
7
|
+
import PodmanHost from "../../../../Models/DatabaseModels/PodmanHost";
|
|
8
|
+
import RumApplication from "../../../../Models/DatabaseModels/RumApplication";
|
|
9
|
+
import ServerlessFunction from "../../../../Models/DatabaseModels/ServerlessFunction";
|
|
10
|
+
import CloudResourceService from "../../../../Server/Services/CloudResourceService";
|
|
11
|
+
import DockerHostService from "../../../../Server/Services/DockerHostService";
|
|
12
|
+
import IoTDeviceService from "../../../../Server/Services/IoTDeviceService";
|
|
13
|
+
import NetworkDeviceService from "../../../../Server/Services/NetworkDeviceService";
|
|
14
|
+
import PodmanHostService from "../../../../Server/Services/PodmanHostService";
|
|
15
|
+
import RumApplicationService from "../../../../Server/Services/RumApplicationService";
|
|
16
|
+
import ServerlessFunctionService from "../../../../Server/Services/ServerlessFunctionService";
|
|
2
17
|
import {
|
|
3
18
|
buildInventoryEntityModel,
|
|
4
19
|
compactAttributes,
|
|
5
20
|
ErasedInventorySource,
|
|
21
|
+
hasCustomFieldValues,
|
|
6
22
|
INVENTORY_SOURCES,
|
|
7
23
|
inventoryEntityNeedsUpdate,
|
|
8
24
|
InventoryRowProjection,
|
|
25
|
+
OrphanPartition,
|
|
26
|
+
partitionOrphanRows,
|
|
9
27
|
} from "../../../../Server/Utils/Telemetry/InventoryEntityRegistry";
|
|
10
28
|
import ColumnLength from "../../../../Types/Database/ColumnLength";
|
|
11
29
|
import Dictionary from "../../../../Types/Dictionary";
|
|
30
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
12
31
|
import ObjectID from "../../../../Types/ObjectID";
|
|
13
32
|
import EntitySource from "../../../../Types/Telemetry/EntitySource";
|
|
14
33
|
import EntityType from "../../../../Types/Telemetry/EntityType";
|
|
@@ -320,3 +339,652 @@ describe("INVENTORY_SOURCES", () => {
|
|
|
320
339
|
}
|
|
321
340
|
});
|
|
322
341
|
});
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* Everything below covers the destructive half of the reconcile.
|
|
345
|
+
*
|
|
346
|
+
* The mapping tested above is worth getting right; this half is worth getting
|
|
347
|
+
* right at a different order of magnitude. A mistake in `buildInventoryEntityModel`
|
|
348
|
+
* shows up as an entity that looks wrong and can be fixed by fixing the code and
|
|
349
|
+
* waiting fifteen minutes. A mistake here hard-deletes rows through
|
|
350
|
+
* `hardDeleteBy`, which bypasses the audit hook — so the serial numbers, warranty
|
|
351
|
+
* dates and owning-team names a person typed into the custom fields of a mirrored
|
|
352
|
+
* row are gone with no undo and no record that they ever existed. Both functions
|
|
353
|
+
* are pure and exported precisely so this can be pinned down without a database.
|
|
354
|
+
*/
|
|
355
|
+
|
|
356
|
+
const LIVE_RESOURCE_ID: ObjectID = new ObjectID(
|
|
357
|
+
"1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
|
358
|
+
);
|
|
359
|
+
const DEAD_RESOURCE_ID: ObjectID = new ObjectID(
|
|
360
|
+
"9f8e7d6c-5b4a-4392-8180-7f6e5d4c3b2a",
|
|
361
|
+
);
|
|
362
|
+
const ROW_ID_ONE: ObjectID = new ObjectID(
|
|
363
|
+
"2c1d0e9f-8a7b-4c6d-9e5f-0a1b2c3d4e5f",
|
|
364
|
+
);
|
|
365
|
+
const ROW_ID_TWO: ObjectID = new ObjectID(
|
|
366
|
+
"3d2e1f0a-9b8c-4d7e-8f6a-1b2c3d4e5f60",
|
|
367
|
+
);
|
|
368
|
+
const ROW_ID_THREE: ObjectID = new ObjectID(
|
|
369
|
+
"4e3f2a1b-0c9d-4e8f-9a7b-2c3d4e5f6071",
|
|
370
|
+
);
|
|
371
|
+
const ROW_ID_FOUR: ObjectID = new ObjectID(
|
|
372
|
+
"5f4a3b2c-1d0e-4f9a-8b6c-3d4e5f607182",
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A registry row as the reverse pass actually reads it: only `_id`,
|
|
377
|
+
* `resourceId`, `customFields` and `isArchived` are selected, so only those are
|
|
378
|
+
* populated here. Keys are assigned rather than set to undefined because
|
|
379
|
+
* `exactOptionalPropertyTypes` distinguishes the two, and so does the code under
|
|
380
|
+
* test — an absent `customFields` is a different shape from a present empty one.
|
|
381
|
+
*/
|
|
382
|
+
function registryRow(
|
|
383
|
+
overrides: {
|
|
384
|
+
id?: ObjectID;
|
|
385
|
+
resourceId?: ObjectID;
|
|
386
|
+
customFields?: JSONObject;
|
|
387
|
+
isArchived?: boolean;
|
|
388
|
+
} = {},
|
|
389
|
+
): InventoryItem {
|
|
390
|
+
const row: InventoryItem = new InventoryItem();
|
|
391
|
+
|
|
392
|
+
if (overrides.id !== undefined) {
|
|
393
|
+
row.id = overrides.id;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (overrides.resourceId !== undefined) {
|
|
397
|
+
row.resourceId = overrides.resourceId;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (overrides.customFields !== undefined) {
|
|
401
|
+
row.customFields = overrides.customFields;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (overrides.isArchived !== undefined) {
|
|
405
|
+
row.isArchived = overrides.isArchived;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return row;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function idStrings(ids: Array<ObjectID>): Array<string> {
|
|
412
|
+
return ids.map((id: ObjectID) => {
|
|
413
|
+
return id.toString();
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
describe("hasCustomFieldValues", () => {
|
|
418
|
+
test("false when the bag was never populated", () => {
|
|
419
|
+
/*
|
|
420
|
+
* The overwhelmingly common case: nobody has ever opened the custom fields
|
|
421
|
+
* editor on this row, so it is a pure projection and safe to delete.
|
|
422
|
+
*/
|
|
423
|
+
expect(hasCustomFieldValues(registryRow())).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("false when the bag is explicitly null", () => {
|
|
427
|
+
// Postgres hands back SQL NULL for a jsonb column that was never written.
|
|
428
|
+
const row: InventoryItem = registryRow();
|
|
429
|
+
(row as unknown as { customFields: unknown }).customFields = null;
|
|
430
|
+
|
|
431
|
+
expect(hasCustomFieldValues(row)).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("false when the stored value is not an object at all", () => {
|
|
435
|
+
/*
|
|
436
|
+
* Defensive: a jsonb column can legally hold a bare scalar. Object.keys on a
|
|
437
|
+
* string would enumerate its character indices and report every row with a
|
|
438
|
+
* corrupted bag as worth keeping forever, which quietly turns the delete pass
|
|
439
|
+
* into a no-op.
|
|
440
|
+
*/
|
|
441
|
+
const row: InventoryItem = registryRow();
|
|
442
|
+
(row as unknown as { customFields: unknown }).customFields =
|
|
443
|
+
"not-an-object";
|
|
444
|
+
|
|
445
|
+
expect(hasCustomFieldValues(row)).toBe(false);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("false for an empty bag", () => {
|
|
449
|
+
/*
|
|
450
|
+
* The UI writes `{}` rather than deleting the column when the last value is
|
|
451
|
+
* cleared, so an empty bag is the fingerprint of "someone looked and then
|
|
452
|
+
* changed their mind" — not of data worth preserving.
|
|
453
|
+
*/
|
|
454
|
+
expect(hasCustomFieldValues(registryRow({ customFields: {} }))).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("false when every value is null, undefined, empty string or empty array", () => {
|
|
458
|
+
/*
|
|
459
|
+
* Same story one level down: clearing a field leaves the key behind with an
|
|
460
|
+
* empty value. A key-count check would keep these rows alive forever and the
|
|
461
|
+
* registry would accumulate projections of devices that no longer exist.
|
|
462
|
+
*/
|
|
463
|
+
const row: InventoryItem = registryRow({
|
|
464
|
+
customFields: {
|
|
465
|
+
"Serial Number": "",
|
|
466
|
+
"Warranty Expires": null,
|
|
467
|
+
"Owning Team": undefined,
|
|
468
|
+
Tags: [],
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(hasCustomFieldValues(row)).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("true when one real value survives among the emptied ones", () => {
|
|
476
|
+
const row: InventoryItem = registryRow({
|
|
477
|
+
customFields: {
|
|
478
|
+
"Serial Number": "FTX1840ABCD",
|
|
479
|
+
"Warranty Expires": "",
|
|
480
|
+
Tags: [],
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
expect(hasCustomFieldValues(row)).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("true for a stored `false`", () => {
|
|
488
|
+
/*
|
|
489
|
+
* The classic bug this guards against: a truthiness test (`if (value)`) reads
|
|
490
|
+
* `false` as absence, so a Boolean custom field answered "no" — the device is
|
|
491
|
+
* NOT under warranty — would be counted as an empty bag and the row would be
|
|
492
|
+
* routed to the hard delete. `false` is an answer somebody gave.
|
|
493
|
+
*/
|
|
494
|
+
expect(
|
|
495
|
+
hasCustomFieldValues(
|
|
496
|
+
registryRow({ customFields: { "Under Warranty": false } }),
|
|
497
|
+
),
|
|
498
|
+
).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("true for a stored `0`", () => {
|
|
502
|
+
// Same trap as `false`: zero spare ports is a measurement, not a blank field.
|
|
503
|
+
expect(
|
|
504
|
+
hasCustomFieldValues(registryRow({ customFields: { "Spare Ports": 0 } })),
|
|
505
|
+
).toBe(true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("true for an array with entries", () => {
|
|
509
|
+
/*
|
|
510
|
+
* MultiSelectDropdown fields store arrays. Only the EMPTY array counts as
|
|
511
|
+
* absence — a populated one is exactly as much user work as a typed string.
|
|
512
|
+
*/
|
|
513
|
+
expect(
|
|
514
|
+
hasCustomFieldValues(
|
|
515
|
+
registryRow({ customFields: { Tags: ["dc1", "rack-4"] } }),
|
|
516
|
+
),
|
|
517
|
+
).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("partitionOrphanRows", () => {
|
|
522
|
+
test("a row whose owning resource still exists is neither deleted nor archived", () => {
|
|
523
|
+
/*
|
|
524
|
+
* The steady state, and by far the most common one. A live row must fall out
|
|
525
|
+
* of both lists entirely: putting it in `archivableIds` would hide every
|
|
526
|
+
* mirrored device from the explorer on the next sweep.
|
|
527
|
+
*/
|
|
528
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
529
|
+
rows: [registryRow({ id: ROW_ID_ONE, resourceId: LIVE_RESOURCE_ID })],
|
|
530
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(partition.deletableIds).toEqual([]);
|
|
534
|
+
expect(partition.archivableIds).toEqual([]);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("a live row carrying custom fields is still left alone", () => {
|
|
538
|
+
// Custom fields must not be a reason to touch a row that is not an orphan.
|
|
539
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
540
|
+
rows: [
|
|
541
|
+
registryRow({
|
|
542
|
+
id: ROW_ID_ONE,
|
|
543
|
+
resourceId: LIVE_RESOURCE_ID,
|
|
544
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
545
|
+
}),
|
|
546
|
+
],
|
|
547
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(partition.deletableIds).toEqual([]);
|
|
551
|
+
expect(partition.archivableIds).toEqual([]);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("an orphan with no custom fields is deletable", () => {
|
|
555
|
+
/*
|
|
556
|
+
* Nothing on this row was authored by a person — every column is rewritten
|
|
557
|
+
* from the owning table on each pass — so with the owning row gone there is
|
|
558
|
+
* literally nothing left to keep.
|
|
559
|
+
*/
|
|
560
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
561
|
+
rows: [registryRow({ id: ROW_ID_ONE, resourceId: DEAD_RESOURCE_ID })],
|
|
562
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(idStrings(partition.deletableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
566
|
+
expect(partition.archivableIds).toEqual([]);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("an orphan carrying custom fields is archived, never deleted", () => {
|
|
570
|
+
/*
|
|
571
|
+
* The data-loss guard, and the reason this function exists at all. Those
|
|
572
|
+
* custom field values live on the registry row and nowhere else, so hard
|
|
573
|
+
* -deleting this row destroys the only copy of the user's serial numbers and
|
|
574
|
+
* warranty dates — with no audit trail, because `hardDeleteBy` bypasses the
|
|
575
|
+
* audit hook, and no undo. Archiving produces the same visible outcome (the
|
|
576
|
+
* row leaves the default list) and leaves the data recoverable.
|
|
577
|
+
*/
|
|
578
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
579
|
+
rows: [
|
|
580
|
+
registryRow({
|
|
581
|
+
id: ROW_ID_ONE,
|
|
582
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
583
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
584
|
+
}),
|
|
585
|
+
],
|
|
586
|
+
liveResourceIds: new Set<string>(),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect(idStrings(partition.archivableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
590
|
+
expect(partition.deletableIds).toEqual([]);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("an orphan that is already archived is skipped entirely", () => {
|
|
594
|
+
/*
|
|
595
|
+
* The sweep runs every fifteen minutes and this row will be an orphan on
|
|
596
|
+
* every one of them. Re-archiving would rewrite `archivedAt` each time, so
|
|
597
|
+
* the timestamp would report the last sweep rather than when the device
|
|
598
|
+
* actually went away — which is the only thing that column is good for.
|
|
599
|
+
*/
|
|
600
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
601
|
+
rows: [
|
|
602
|
+
registryRow({
|
|
603
|
+
id: ROW_ID_ONE,
|
|
604
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
605
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
606
|
+
isArchived: true,
|
|
607
|
+
}),
|
|
608
|
+
],
|
|
609
|
+
liveResourceIds: new Set<string>(),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
expect(partition.deletableIds).toEqual([]);
|
|
613
|
+
expect(partition.archivableIds).toEqual([]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("an already-archived orphan with no custom fields is still deletable", () => {
|
|
617
|
+
/*
|
|
618
|
+
* The archive flag only shields rows that hold something. An empty
|
|
619
|
+
* projection that was archived by hand is still an empty projection, and
|
|
620
|
+
* leaving it behind would let the registry grow without bound.
|
|
621
|
+
*/
|
|
622
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
623
|
+
rows: [
|
|
624
|
+
registryRow({
|
|
625
|
+
id: ROW_ID_ONE,
|
|
626
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
627
|
+
isArchived: true,
|
|
628
|
+
}),
|
|
629
|
+
],
|
|
630
|
+
liveResourceIds: new Set<string>(),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
expect(idStrings(partition.deletableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
634
|
+
expect(partition.archivableIds).toEqual([]);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("a row with no resourceId at all is an orphan by definition", () => {
|
|
638
|
+
/*
|
|
639
|
+
* Without a pointer there is nothing to match against the owning table, so
|
|
640
|
+
* the row can never be proven live. Treating it as live instead would leave
|
|
641
|
+
* pre-pointer rows in the registry permanently.
|
|
642
|
+
*/
|
|
643
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
644
|
+
rows: [registryRow({ id: ROW_ID_ONE })],
|
|
645
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
expect(idStrings(partition.deletableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
649
|
+
expect(partition.archivableIds).toEqual([]);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("a pointerless row carrying custom fields is archived, not deleted", () => {
|
|
653
|
+
// The data-loss guard has to win over the pointerless-means-orphan rule too.
|
|
654
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
655
|
+
rows: [
|
|
656
|
+
registryRow({
|
|
657
|
+
id: ROW_ID_ONE,
|
|
658
|
+
customFields: { "Owning Team": "Network Engineering" },
|
|
659
|
+
}),
|
|
660
|
+
],
|
|
661
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(idStrings(partition.archivableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
665
|
+
expect(partition.deletableIds).toEqual([]);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("a row with no id is skipped rather than emitted as a null target", () => {
|
|
669
|
+
/*
|
|
670
|
+
* Should not happen — `_id` is always selected — but an undefined slipping
|
|
671
|
+
* into the id list would become a `WHERE _id IN (NULL)` and, depending on how
|
|
672
|
+
* the query builder folds it, either a no-op or something much worse.
|
|
673
|
+
*/
|
|
674
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
675
|
+
rows: [
|
|
676
|
+
registryRow({
|
|
677
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
678
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
679
|
+
}),
|
|
680
|
+
registryRow({ resourceId: DEAD_RESOURCE_ID }),
|
|
681
|
+
],
|
|
682
|
+
liveResourceIds: new Set<string>(),
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
expect(partition.deletableIds).toEqual([]);
|
|
686
|
+
expect(partition.archivableIds).toEqual([]);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("a mixed page splits correctly and the two lists are disjoint", () => {
|
|
690
|
+
/*
|
|
691
|
+
* The realistic shape of one page: a live row, two orphans that differ only
|
|
692
|
+
* in whether anyone typed anything, and one orphan already archived. Nothing
|
|
693
|
+
* may appear in both lists — the reverse pass runs the delete and the update
|
|
694
|
+
* as two separate statements, so an id in both would be deleted and then
|
|
695
|
+
* "archived" into nothing.
|
|
696
|
+
*/
|
|
697
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
698
|
+
rows: [
|
|
699
|
+
registryRow({ id: ROW_ID_ONE, resourceId: LIVE_RESOURCE_ID }),
|
|
700
|
+
registryRow({ id: ROW_ID_TWO, resourceId: DEAD_RESOURCE_ID }),
|
|
701
|
+
registryRow({
|
|
702
|
+
id: ROW_ID_THREE,
|
|
703
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
704
|
+
customFields: { "Warranty Expires": "2027-01-31T00:00:00.000Z" },
|
|
705
|
+
}),
|
|
706
|
+
registryRow({
|
|
707
|
+
id: ROW_ID_FOUR,
|
|
708
|
+
resourceId: DEAD_RESOURCE_ID,
|
|
709
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
710
|
+
isArchived: true,
|
|
711
|
+
}),
|
|
712
|
+
],
|
|
713
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const deletable: Array<string> = idStrings(partition.deletableIds);
|
|
717
|
+
const archivable: Array<string> = idStrings(partition.archivableIds);
|
|
718
|
+
|
|
719
|
+
expect(deletable).toEqual([ROW_ID_TWO.toString()]);
|
|
720
|
+
expect(archivable).toEqual([ROW_ID_THREE.toString()]);
|
|
721
|
+
|
|
722
|
+
const overlap: Array<string> = deletable.filter((id: string) => {
|
|
723
|
+
return archivable.includes(id);
|
|
724
|
+
});
|
|
725
|
+
expect(overlap).toEqual([]);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("an empty page produces an empty partition rather than throwing", () => {
|
|
729
|
+
// Every source hits this on its last page; a throw here would abort the sweep.
|
|
730
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
731
|
+
rows: [],
|
|
732
|
+
liveResourceIds: new Set<string>([LIVE_RESOURCE_ID.toString()]),
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
expect(partition.deletableIds).toEqual([]);
|
|
736
|
+
expect(partition.archivableIds).toEqual([]);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("an empty live set orphans every row on the page", () => {
|
|
740
|
+
/*
|
|
741
|
+
* What a fully emptied inventory table looks like — a project deleted by FK
|
|
742
|
+
* cascade, which is the case hooks could never have caught. The rows with
|
|
743
|
+
* typed values must survive it.
|
|
744
|
+
*/
|
|
745
|
+
const partition: OrphanPartition = partitionOrphanRows({
|
|
746
|
+
rows: [
|
|
747
|
+
registryRow({ id: ROW_ID_ONE, resourceId: LIVE_RESOURCE_ID }),
|
|
748
|
+
registryRow({
|
|
749
|
+
id: ROW_ID_TWO,
|
|
750
|
+
resourceId: LIVE_RESOURCE_ID,
|
|
751
|
+
customFields: { "Serial Number": "FTX1840ABCD" },
|
|
752
|
+
}),
|
|
753
|
+
],
|
|
754
|
+
liveResourceIds: new Set<string>(),
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
expect(idStrings(partition.deletableIds)).toEqual([ROW_ID_ONE.toString()]);
|
|
758
|
+
expect(idStrings(partition.archivableIds)).toEqual([ROW_ID_TWO.toString()]);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
/*
|
|
763
|
+
* The per-source `query` is captured inside the closure `defineInventorySource`
|
|
764
|
+
* returns, so the only way to observe it is to let the closure run and watch what
|
|
765
|
+
* it asks the service for. `findBy` is a prototype method on DatabaseService, so
|
|
766
|
+
* assigning a stub onto the singleton shadows it and deleting the own property
|
|
767
|
+
* afterwards restores the real one — no module mocking, and nothing left behind
|
|
768
|
+
* for the next test file in the worker.
|
|
769
|
+
*/
|
|
770
|
+
interface StubbableService {
|
|
771
|
+
findBy?: (data: { query: JSONObject }) => Promise<Array<never>>;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const SERVICE_BY_RESOURCE_TYPE: Dictionary<StubbableService> = {
|
|
775
|
+
NetworkDevice: NetworkDeviceService as unknown as StubbableService,
|
|
776
|
+
CloudResource: CloudResourceService as unknown as StubbableService,
|
|
777
|
+
ServerlessFunction: ServerlessFunctionService as unknown as StubbableService,
|
|
778
|
+
RumApplication: RumApplicationService as unknown as StubbableService,
|
|
779
|
+
IoTDevice: IoTDeviceService as unknown as StubbableService,
|
|
780
|
+
DockerHost: DockerHostService as unknown as StubbableService,
|
|
781
|
+
PodmanHost: PodmanHostService as unknown as StubbableService,
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
const MODEL_BY_RESOURCE_TYPE: Dictionary<BaseModel> = {
|
|
785
|
+
NetworkDevice: new NetworkDevice(),
|
|
786
|
+
CloudResource: new CloudResource(),
|
|
787
|
+
ServerlessFunction: new ServerlessFunction(),
|
|
788
|
+
RumApplication: new RumApplication(),
|
|
789
|
+
IoTDevice: new IoTDevice(),
|
|
790
|
+
DockerHost: new DockerHost(),
|
|
791
|
+
PodmanHost: new PodmanHost(),
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
async function captureServiceQueries(data: {
|
|
795
|
+
resourceType: string;
|
|
796
|
+
run: () => Promise<void>;
|
|
797
|
+
}): Promise<Array<JSONObject>> {
|
|
798
|
+
const service: StubbableService | undefined =
|
|
799
|
+
SERVICE_BY_RESOURCE_TYPE[data.resourceType];
|
|
800
|
+
|
|
801
|
+
if (!service) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`No service registered in this test for ${data.resourceType}. A new inventory source landed; add it here so its archive filter is covered too.`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const seen: Array<JSONObject> = [];
|
|
808
|
+
|
|
809
|
+
service.findBy = (call: { query: JSONObject }): Promise<Array<never>> => {
|
|
810
|
+
seen.push(call.query);
|
|
811
|
+
return Promise.resolve([]);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
try {
|
|
815
|
+
await data.run();
|
|
816
|
+
} finally {
|
|
817
|
+
delete service.findBy;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return seen;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function mirrorQueryFor(
|
|
824
|
+
source: ErasedInventorySource,
|
|
825
|
+
): Promise<JSONObject> {
|
|
826
|
+
const seen: Array<JSONObject> = await captureServiceQueries({
|
|
827
|
+
resourceType: source.resourceType,
|
|
828
|
+
run: async (): Promise<void> => {
|
|
829
|
+
await source.fetchPage({ skip: 0, limit: 1 });
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(seen.length).toBe(1);
|
|
834
|
+
return seen[0] || {};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function modelFor(resourceType: string): BaseModel {
|
|
838
|
+
const model: BaseModel | undefined = MODEL_BY_RESOURCE_TYPE[resourceType];
|
|
839
|
+
|
|
840
|
+
if (!model) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`No model registered in this test for ${resourceType}. A new inventory source landed; add it here so its archive filter is covered too.`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return model;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function hasArchiveColumn(resourceType: string): boolean {
|
|
850
|
+
return modelFor(resourceType)
|
|
851
|
+
.getTableColumns()
|
|
852
|
+
.columns.includes("isArchived");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function sourceFor(resourceType: string): ErasedInventorySource {
|
|
856
|
+
const source: ErasedInventorySource | undefined = INVENTORY_SOURCES.find(
|
|
857
|
+
(candidate: ErasedInventorySource) => {
|
|
858
|
+
return candidate.resourceType === resourceType;
|
|
859
|
+
},
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
expect(source).toBeDefined();
|
|
863
|
+
return source!;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
describe("INVENTORY_SOURCES archive filtering", () => {
|
|
867
|
+
test("the NetworkDevice source excludes archived rows", async () => {
|
|
868
|
+
/*
|
|
869
|
+
* This one is pinned by name because it regressed once already: the query
|
|
870
|
+
* was `{}` under a comment claiming NetworkDevice had no archive flag. It
|
|
871
|
+
* does — NetworkDevice.isArchived — so archived switches were mirrored back
|
|
872
|
+
* into the explorer on every sweep, and archiving one in the UI appeared to
|
|
873
|
+
* do nothing at all.
|
|
874
|
+
*/
|
|
875
|
+
const query: JSONObject = await mirrorQueryFor(sourceFor("NetworkDevice"));
|
|
876
|
+
|
|
877
|
+
expect(query).toEqual({ isArchived: false });
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("NetworkDevice really does have the archive column the query filters on", () => {
|
|
881
|
+
// The other half of the regression above: the claim, not just the query.
|
|
882
|
+
expect(hasArchiveColumn("NetworkDevice")).toBe(true);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("every source whose model has an archive column filters on it", async () => {
|
|
886
|
+
/*
|
|
887
|
+
* Checked against the real models rather than a hardcoded list, so adding a
|
|
888
|
+
* source — or adding `isArchived` to a model that lacked it — fails here
|
|
889
|
+
* instead of silently reintroducing the NetworkDevice bug somewhere else.
|
|
890
|
+
*/
|
|
891
|
+
for (const source of INVENTORY_SOURCES) {
|
|
892
|
+
if (!hasArchiveColumn(source.resourceType)) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const query: JSONObject = await mirrorQueryFor(source);
|
|
897
|
+
expect({ resourceType: source.resourceType, query: query }).toEqual({
|
|
898
|
+
resourceType: source.resourceType,
|
|
899
|
+
query: { isArchived: false },
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("IoTDevice is the only model without an archive column", () => {
|
|
905
|
+
/*
|
|
906
|
+
* The exemption is a property of the schema, not a decision this module gets
|
|
907
|
+
* to make: IoTDevice genuinely has no `isArchived`, so it is the only source
|
|
908
|
+
* that may legitimately mirror everything.
|
|
909
|
+
*/
|
|
910
|
+
const withoutArchiveColumn: Array<string> = INVENTORY_SOURCES.filter(
|
|
911
|
+
(source: ErasedInventorySource) => {
|
|
912
|
+
return !hasArchiveColumn(source.resourceType);
|
|
913
|
+
},
|
|
914
|
+
).map((source: ErasedInventorySource) => {
|
|
915
|
+
return source.resourceType;
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
expect(withoutArchiveColumn).toEqual(["IoTDevice"]);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("IoTDevice is the only source with an unrestricted query", async () => {
|
|
922
|
+
const unrestricted: Array<string> = [];
|
|
923
|
+
|
|
924
|
+
for (const source of INVENTORY_SOURCES) {
|
|
925
|
+
const query: JSONObject = await mirrorQueryFor(source);
|
|
926
|
+
if (Object.keys(query).length === 0) {
|
|
927
|
+
unrestricted.push(source.resourceType);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
expect(unrestricted).toEqual(["IoTDevice"]);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("no source narrows the mirror on anything but the archive flag", async () => {
|
|
935
|
+
/*
|
|
936
|
+
* The mirror is meant to be a view of the whole live estate. A stray extra
|
|
937
|
+
* condition here would silently hide a slice of it, and the missing devices
|
|
938
|
+
* would look identical to devices that were never registered.
|
|
939
|
+
*/
|
|
940
|
+
for (const source of INVENTORY_SOURCES) {
|
|
941
|
+
const query: JSONObject = await mirrorQueryFor(source);
|
|
942
|
+
for (const key of Object.keys(query)) {
|
|
943
|
+
expect({ resourceType: source.resourceType, key: key }).toEqual({
|
|
944
|
+
resourceType: source.resourceType,
|
|
945
|
+
key: "isArchived",
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("findLiveIds does not inherit the archive filter", async () => {
|
|
952
|
+
/*
|
|
953
|
+
* `findLiveIds` answers "does the owning row still exist", and an archived
|
|
954
|
+
* row still exists. Reusing the mirror query here would make every archived
|
|
955
|
+
* device look like a cascade delete, so the reverse pass would hard-delete
|
|
956
|
+
* its registry row — turning archiving, a reversible UI action, into
|
|
957
|
+
* permanent data loss for exactly the rows this feature set out to protect.
|
|
958
|
+
*/
|
|
959
|
+
const source: ErasedInventorySource = sourceFor("NetworkDevice");
|
|
960
|
+
|
|
961
|
+
const seen: Array<JSONObject> = await captureServiceQueries({
|
|
962
|
+
resourceType: source.resourceType,
|
|
963
|
+
run: async (): Promise<void> => {
|
|
964
|
+
await source.findLiveIds([LIVE_RESOURCE_ID]);
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
expect(seen.length).toBe(1);
|
|
969
|
+
expect(Object.keys(seen[0] || {})).toEqual(["_id"]);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("findLiveIds asks nothing at all for an empty id list", async () => {
|
|
973
|
+
/*
|
|
974
|
+
* An empty `WHERE _id IN ()` is a syntax error in some builders and a match
|
|
975
|
+
* -everything in others; short-circuiting is the only safe answer, and it
|
|
976
|
+
* also spares seven pointless round trips on an empty estate.
|
|
977
|
+
*/
|
|
978
|
+
const source: ErasedInventorySource = sourceFor("NetworkDevice");
|
|
979
|
+
|
|
980
|
+
const seen: Array<JSONObject> = await captureServiceQueries({
|
|
981
|
+
resourceType: source.resourceType,
|
|
982
|
+
run: async (): Promise<void> => {
|
|
983
|
+
const live: Set<string> = await source.findLiveIds([]);
|
|
984
|
+
expect(live.size).toBe(0);
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
expect(seen).toEqual([]);
|
|
989
|
+
});
|
|
990
|
+
});
|