@oneuptime/common 12.0.10 → 12.0.12

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.
Files changed (98) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
  9. package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
  10. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
  11. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  12. package/Server/Utils/VM/VMRunner.ts +693 -874
  13. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  14. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  15. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  16. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  17. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  18. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  19. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  20. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  21. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  22. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  23. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  24. package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
  25. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
  26. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  27. package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
  28. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
  29. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  30. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  31. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  32. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  33. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  34. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  35. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  36. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  37. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  38. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  39. package/Types/CustomField/CustomFieldType.ts +28 -0
  40. package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
  41. package/Types/Monitor/MonitorType.ts +11 -0
  42. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  43. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  44. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  45. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  46. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  47. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  48. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  49. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  50. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  51. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  52. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  53. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  54. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  55. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  56. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  57. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  58. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  60. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  61. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  62. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  63. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  64. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  65. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  66. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
  67. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  68. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  69. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  70. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
  71. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  72. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  73. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  74. package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
  75. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  76. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  77. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  78. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  79. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  80. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  81. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  82. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  83. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  84. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  85. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  86. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  87. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  88. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  89. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  90. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  91. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  92. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  93. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  94. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  95. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  96. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  97. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  98. package/package.json +1 -1
@@ -3,9 +3,12 @@ import { NetworkTopologyDeviceRole } from "../../../Types/Monitor/SnmpMonitor/Ne
3
3
  import {
4
4
  DEVICE_ROLES_IN_LEGEND_ORDER,
5
5
  DEVICE_ROLE_LABELS,
6
+ DeviceRoleSignals,
6
7
  classifyDeviceRole,
7
8
  classifyEndpointRole,
8
9
  labelForDeviceRole,
10
+ parseDeviceRoleOverride,
11
+ resolveDeviceRole,
9
12
  } from "../../../Utils/Monitor/NetworkDeviceRoleUtil";
10
13
 
11
14
  /*
@@ -512,3 +515,502 @@ describe("labels", () => {
512
515
  );
513
516
  });
514
517
  });
518
+
519
+ /*
520
+ * The operator's override (NetworkDevice.deviceRole), from issue #3192.
521
+ *
522
+ * The classifier above only ever sees SNMP, so a device that answers
523
+ * nothing but ping is unclassifiable forever — it is drawn "unknown" and
524
+ * no amount of re-polling changes that. The override is the operator
525
+ * saying what the box IS, which is the only statement about a role in the
526
+ * system that is not an inference, so it has to beat every tier of
527
+ * evidence. Everything below is about the two halves of that promise:
528
+ * parseDeviceRoleOverride deciding whether a stored string is a
529
+ * declaration at all, and resolveDeviceRole deciding who wins when it is.
530
+ */
531
+
532
+ /*
533
+ * A real switch, described by every tier at once: named product family in
534
+ * the model, the vendor's boilerplate in sysDescr, Cisco's arc in the OID,
535
+ * and a hostname that follows the convention. Nothing about this device is
536
+ * ambiguous, which is exactly why it is the right device to point an
537
+ * override at — if the override wins here it wins anywhere.
538
+ */
539
+ const CATALYST_SWITCH_SIGNALS: DeviceRoleSignals = {
540
+ vendor: "Cisco Systems",
541
+ deviceModel: "WS-C2960X-48FPD-L",
542
+ sysDescr: "Cisco IOS Software, C2960X Software, Catalyst 2960X switch",
543
+ sysObjectId: "1.3.6.1.4.1.9.1.1745",
544
+ sysName: "idf2-sw-3",
545
+ name: "idf2-sw-3",
546
+ };
547
+
548
+ describe("parseDeviceRoleOverride — the values it accepts", () => {
549
+ test("every role a legend can show round-trips through the column", () => {
550
+ /*
551
+ * The column is what the picker writes and what the topology builder
552
+ * reads back, so the set it accepts has to be exactly the set the UI
553
+ * offers. Looping the legend rather than listing the roles means a new
554
+ * role cannot be added to the type without this test covering it.
555
+ */
556
+ for (const role of DEVICE_ROLES_IN_LEGEND_ORDER) {
557
+ const parsed: NetworkTopologyDeviceRole | undefined =
558
+ parseDeviceRoleOverride(role);
559
+ expect(parsed).toBe(role);
560
+ }
561
+ });
562
+
563
+ test("matching ignores the case the value was stored in", () => {
564
+ /*
565
+ * Values arrive from a form, an API caller and a seed script, and none
566
+ * of them agree about casing.
567
+ */
568
+ expect(parseDeviceRoleOverride("switch")).toBe("switch");
569
+ expect(parseDeviceRoleOverride("SWITCH")).toBe("switch");
570
+ expect(parseDeviceRoleOverride("Switch")).toBe("switch");
571
+ expect(parseDeviceRoleOverride("sWiTcH")).toBe("switch");
572
+ });
573
+
574
+ test("the camelCase roles survive every casing, which a one-sided compare would break", () => {
575
+ /*
576
+ * wirelessAccessPoint and loadBalancer are the only two role keys with
577
+ * an internal capital, so they are the two a naive
578
+ * `role === value.toLowerCase()` would silently drop — the stored value
579
+ * would look valid, parse to undefined, and the classifier would
580
+ * quietly overrule the operator. Both sides have to be lowercased.
581
+ */
582
+ expect(parseDeviceRoleOverride("wirelessAccessPoint")).toBe(
583
+ "wirelessAccessPoint",
584
+ );
585
+ expect(parseDeviceRoleOverride("wirelessaccesspoint")).toBe(
586
+ "wirelessAccessPoint",
587
+ );
588
+ expect(parseDeviceRoleOverride("WIRELESSACCESSPOINT")).toBe(
589
+ "wirelessAccessPoint",
590
+ );
591
+ expect(parseDeviceRoleOverride("WirelessAccessPoint")).toBe(
592
+ "wirelessAccessPoint",
593
+ );
594
+
595
+ expect(parseDeviceRoleOverride("loadBalancer")).toBe("loadBalancer");
596
+ expect(parseDeviceRoleOverride("loadbalancer")).toBe("loadBalancer");
597
+ expect(parseDeviceRoleOverride("LOADBALANCER")).toBe("loadBalancer");
598
+ expect(parseDeviceRoleOverride("LoadBalancer")).toBe("loadBalancer");
599
+ });
600
+
601
+ test("surrounding whitespace is trimmed off before matching", () => {
602
+ // Copy-paste into a text field is the normal way a stray space arrives.
603
+ expect(parseDeviceRoleOverride(" router ")).toBe("router");
604
+ expect(parseDeviceRoleOverride("\tswitch")).toBe("switch");
605
+ expect(parseDeviceRoleOverride("firewall\n")).toBe("firewall");
606
+ expect(parseDeviceRoleOverride(" loadBalancer ")).toBe("loadBalancer");
607
+ expect(parseDeviceRoleOverride("\n wirelessaccesspoint \t ")).toBe(
608
+ "wirelessAccessPoint",
609
+ );
610
+ });
611
+ });
612
+
613
+ describe("parseDeviceRoleOverride — the values it refuses", () => {
614
+ test("absent, empty and blank all mean no override rather than a role", () => {
615
+ /*
616
+ * Undefined is the answer that lets the classifier run, so every way of
617
+ * saying "the operator never filled this in" — a null column, an empty
618
+ * string from a cleared form, a field containing only spaces — has to
619
+ * land on it.
620
+ */
621
+ expect(parseDeviceRoleOverride(undefined)).toBeUndefined();
622
+ expect(parseDeviceRoleOverride(null)).toBeUndefined();
623
+ expect(parseDeviceRoleOverride("")).toBeUndefined();
624
+ expect(parseDeviceRoleOverride(" ")).toBeUndefined();
625
+ expect(parseDeviceRoleOverride("\t\n ")).toBeUndefined();
626
+ });
627
+
628
+ test('"unknown" is deliberately refused even though it is a real role', () => {
629
+ /*
630
+ * This is the one refusal that is a design decision rather than a
631
+ * validation failure. Empty already means "classify it", so storing
632
+ * "unknown" could only mean the different and much less useful thing:
633
+ * pin this device to the neutral shape and switch the classifier off
634
+ * for good. An operator who does not know what a box is wants the
635
+ * classifier to keep trying, not to be silenced — so "unknown" is not
636
+ * in DEVICE_ROLES_IN_LEGEND_ORDER and parses as no override at all.
637
+ */
638
+ expect(parseDeviceRoleOverride("unknown")).toBeUndefined();
639
+ expect(parseDeviceRoleOverride("Unknown")).toBeUndefined();
640
+ expect(parseDeviceRoleOverride("UNKNOWN")).toBeUndefined();
641
+ expect(parseDeviceRoleOverride(" unknown ")).toBeUndefined();
642
+ });
643
+
644
+ test("unrecognised text is refused rather than partially matched", () => {
645
+ /*
646
+ * The match is whole-string equality on purpose: a prefix or substring
647
+ * rule would let "rout" or "router-ish" mean router, and an override
648
+ * that quietly means something adjacent to what was typed is worse
649
+ * than one that does nothing.
650
+ */
651
+ expect(parseDeviceRoleOverride("banana")).toBeUndefined();
652
+ expect(parseDeviceRoleOverride("rout")).toBeUndefined();
653
+ expect(parseDeviceRoleOverride("router-ish")).toBeUndefined();
654
+ expect(parseDeviceRoleOverride("routers")).toBeUndefined();
655
+ expect(parseDeviceRoleOverride("sw")).toBeUndefined();
656
+ expect(parseDeviceRoleOverride("core-sw-1")).toBeUndefined();
657
+ expect(parseDeviceRoleOverride("42")).toBeUndefined();
658
+ });
659
+
660
+ test("only the surrounding whitespace is forgiven, never the internal kind", () => {
661
+ /*
662
+ * Trimming the ends is a paste artefact; rewriting the middle would be
663
+ * guessing. "wireless access point" is prose, not the stored value, so
664
+ * it is refused — while the same letters with only outer padding are
665
+ * accepted (covered above).
666
+ */
667
+ expect(parseDeviceRoleOverride("wireless access point")).toBeUndefined();
668
+ expect(parseDeviceRoleOverride("wireless accesspoint")).toBeUndefined();
669
+ expect(parseDeviceRoleOverride("wireless-access-point")).toBeUndefined();
670
+ expect(parseDeviceRoleOverride("wireless_access_point")).toBeUndefined();
671
+ expect(parseDeviceRoleOverride("load balancer")).toBeUndefined();
672
+ expect(parseDeviceRoleOverride("load-balancer")).toBeUndefined();
673
+ });
674
+
675
+ test("the display labels are not storable values", () => {
676
+ /*
677
+ * DEVICE_ROLE_LABELS is what a human reads; the column holds the role
678
+ * key. The multi-word labels are the ones that would break if a UI ever
679
+ * wrote the label back, so they must not silently parse.
680
+ */
681
+ expect(
682
+ parseDeviceRoleOverride(DEVICE_ROLE_LABELS.wirelessAccessPoint),
683
+ ).toBe(undefined);
684
+ expect(parseDeviceRoleOverride(DEVICE_ROLE_LABELS.loadBalancer)).toBe(
685
+ undefined,
686
+ );
687
+ expect(parseDeviceRoleOverride(DEVICE_ROLE_LABELS.phone)).toBe(undefined);
688
+ expect(parseDeviceRoleOverride(DEVICE_ROLE_LABELS.unknown)).toBe(undefined);
689
+ });
690
+ });
691
+
692
+ describe("parseDeviceRoleOverride — determinism", () => {
693
+ test("the same value parses the same way however many times it is asked", () => {
694
+ /*
695
+ * Cheap insurance against any future implementation that reaches for a
696
+ * shared regex (whose lastIndex is stateful) or a memo keyed on
697
+ * something mutable: the same input must not answer differently on the
698
+ * second call, or a topology would depend on how many devices were
699
+ * built before it.
700
+ */
701
+ const values: Array<string> = [
702
+ "switch",
703
+ "SWITCH",
704
+ "unknown",
705
+ "banana",
706
+ "loadBalancer",
707
+ "switch",
708
+ ];
709
+ const firstPass: Array<NetworkTopologyDeviceRole | undefined> = values.map(
710
+ (value: string) => {
711
+ return parseDeviceRoleOverride(value);
712
+ },
713
+ );
714
+ const secondPass: Array<NetworkTopologyDeviceRole | undefined> = values.map(
715
+ (value: string) => {
716
+ return parseDeviceRoleOverride(value);
717
+ },
718
+ );
719
+
720
+ expect(secondPass).toEqual(firstPass);
721
+ expect(firstPass).toEqual([
722
+ "switch",
723
+ "switch",
724
+ undefined,
725
+ undefined,
726
+ "loadBalancer",
727
+ "switch",
728
+ ]);
729
+ });
730
+
731
+ test("parsing the legend backwards gives the same answers as parsing it forwards", () => {
732
+ /*
733
+ * The order the caller happens to iterate in must not leak into the
734
+ * result — the same guarantee the topology builder relies on when it
735
+ * resolves devices in whatever order the database returned them.
736
+ */
737
+ const forwards: Array<NetworkTopologyDeviceRole | undefined> =
738
+ DEVICE_ROLES_IN_LEGEND_ORDER.map((role: NetworkTopologyDeviceRole) => {
739
+ return parseDeviceRoleOverride(role);
740
+ });
741
+ const backwards: Array<NetworkTopologyDeviceRole | undefined> = [
742
+ ...DEVICE_ROLES_IN_LEGEND_ORDER,
743
+ ]
744
+ .reverse()
745
+ .map((role: NetworkTopologyDeviceRole) => {
746
+ return parseDeviceRoleOverride(role);
747
+ });
748
+
749
+ expect(backwards).toEqual([...forwards].reverse());
750
+ expect(forwards).toEqual([...DEVICE_ROLES_IN_LEGEND_ORDER]);
751
+ });
752
+ });
753
+
754
+ describe("resolveDeviceRole — a stored override outranks the evidence", () => {
755
+ test("an override beats a sysDescr that names the product family outright", () => {
756
+ /*
757
+ * The device is unmistakably a Catalyst switch by every tier the
758
+ * classifier has. The operator says it is a camera; the operator wins,
759
+ * because the classifier is inferring and the operator is not. (In
760
+ * practice this is a re-used chassis, a lab rig, or a device the
761
+ * operator wants grouped with the cameras on the map.)
762
+ */
763
+ expect(resolveDeviceRole("camera", CATALYST_SWITCH_SIGNALS)).toBe("camera");
764
+ });
765
+
766
+ test("an override beats every tier of evidence, one role at a time", () => {
767
+ /*
768
+ * Exhaustive rather than illustrative: whichever role an operator
769
+ * picks, that role is what gets drawn, even on the least ambiguous
770
+ * device in these tests.
771
+ */
772
+ for (const role of DEVICE_ROLES_IN_LEGEND_ORDER) {
773
+ expect(resolveDeviceRole(role, CATALYST_SWITCH_SIGNALS)).toBe(role);
774
+ }
775
+ });
776
+
777
+ test("an override beats a matching vendor arc and a matching hostname too", () => {
778
+ // Fortinet's arc plus a "fw" hostname would both say firewall.
779
+ expect(
780
+ resolveDeviceRole("loadBalancer", {
781
+ sysObjectId: "1.3.6.1.4.1.12356.101.1.1",
782
+ sysDescr: "FortiGate-60F v7.2.5",
783
+ name: "edge-fw01",
784
+ }),
785
+ ).toBe("loadBalancer");
786
+ });
787
+
788
+ test("the override is honoured in whatever casing it was stored in", () => {
789
+ expect(resolveDeviceRole("CAMERA", CATALYST_SWITCH_SIGNALS)).toBe("camera");
790
+ expect(
791
+ resolveDeviceRole(" wirelessaccesspoint ", CATALYST_SWITCH_SIGNALS),
792
+ ).toBe("wirelessAccessPoint");
793
+ });
794
+ });
795
+
796
+ describe("resolveDeviceRole — the classifier runs when there is no override", () => {
797
+ test("absent, null, empty and blank overrides all hand back to the classifier", () => {
798
+ expect(resolveDeviceRole(undefined, CATALYST_SWITCH_SIGNALS)).toBe(
799
+ "switch",
800
+ );
801
+ expect(resolveDeviceRole(null, CATALYST_SWITCH_SIGNALS)).toBe("switch");
802
+ expect(resolveDeviceRole("", CATALYST_SWITCH_SIGNALS)).toBe("switch");
803
+ expect(resolveDeviceRole(" ", CATALYST_SWITCH_SIGNALS)).toBe("switch");
804
+ });
805
+
806
+ test("an unrecognised override is ignored, not treated as a shrug", () => {
807
+ /*
808
+ * Garbage in the column must not disable the classifier — a typo or a
809
+ * value written by an older client should degrade to the behaviour the
810
+ * device had before the column existed.
811
+ */
812
+ expect(resolveDeviceRole("banana", CATALYST_SWITCH_SIGNALS)).toBe("switch");
813
+ expect(resolveDeviceRole("rout", CATALYST_SWITCH_SIGNALS)).toBe("switch");
814
+ expect(resolveDeviceRole("router-ish", CATALYST_SWITCH_SIGNALS)).toBe(
815
+ "switch",
816
+ );
817
+ });
818
+
819
+ test('a stored "unknown" leaves the classifier in charge — the refusal, seen end to end', () => {
820
+ /*
821
+ * The payoff of parseDeviceRoleOverride refusing "unknown": the device
822
+ * is still classified as a switch rather than being pinned neutral.
823
+ */
824
+ expect(resolveDeviceRole("unknown", CATALYST_SWITCH_SIGNALS)).toBe(
825
+ "switch",
826
+ );
827
+ /*
828
+ * And on a device with nothing to go on, the classifier's own answer is
829
+ * "unknown" anyway, so the two paths agree where it matters.
830
+ */
831
+ expect(resolveDeviceRole("unknown", {})).toBe("unknown");
832
+ });
833
+
834
+ test("the classifier's full precedence chain still applies underneath", () => {
835
+ // A spot check that resolveDeviceRole is a wrapper, not a second opinion.
836
+ expect(
837
+ resolveDeviceRole(undefined, {
838
+ sysDescr: "Linux gw-1 5.4.0-91-generic x86_64",
839
+ name: "gw-1",
840
+ }),
841
+ ).toBe("router");
842
+ expect(
843
+ resolveDeviceRole("", { sysObjectId: "1.3.6.1.4.1.3375.2.1.3" }),
844
+ ).toBe("loadBalancer");
845
+ });
846
+ });
847
+
848
+ describe("resolveDeviceRole — the ping-only device from issue #3192", () => {
849
+ test("a device with no signals and no override is unknown, as it always was", () => {
850
+ /*
851
+ * The starting position the issue describes: discovery imported a box
852
+ * that answers ICMP and nothing else, so there is no sysDescr, no
853
+ * sysObjectId, no model — and the map draws a neutral node.
854
+ */
855
+ expect(resolveDeviceRole(undefined, {})).toBe("unknown");
856
+ expect(
857
+ resolveDeviceRole(undefined, {
858
+ sysDescr: "",
859
+ sysObjectId: "",
860
+ deviceModel: undefined,
861
+ name: "10.20.30.40",
862
+ }),
863
+ ).toBe("unknown");
864
+ });
865
+
866
+ test("a device with no signals but an override is drawn as the operator declared", () => {
867
+ /*
868
+ * The whole point of the feature. Nothing SNMP could ever say about
869
+ * this device will change, so the override is the only way it will ever
870
+ * be anything other than an anonymous circle — and it works with an
871
+ * entirely empty evidence bundle, which is the state a ping-only
872
+ * device is permanently in.
873
+ */
874
+ expect(resolveDeviceRole("router", {})).toBe("router");
875
+ expect(resolveDeviceRole("switch", {})).toBe("switch");
876
+ expect(resolveDeviceRole("firewall", {})).toBe("firewall");
877
+ expect(resolveDeviceRole("wirelessAccessPoint", {})).toBe(
878
+ "wirelessAccessPoint",
879
+ );
880
+ });
881
+
882
+ test("an override on a ping-only device survives an uninformative hostname", () => {
883
+ /*
884
+ * "device-42" and a bare IP are what these imports are usually called;
885
+ * neither is a hostname convention the classifier can read, and neither
886
+ * gets to argue with the declaration.
887
+ */
888
+ expect(resolveDeviceRole("router", { name: "device-42" })).toBe("router");
889
+ expect(resolveDeviceRole("printer", { name: "10.20.30.40" })).toBe(
890
+ "printer",
891
+ );
892
+ });
893
+
894
+ test("an override still wins once the device later starts answering SNMP", () => {
895
+ /*
896
+ * A ping-only device that is given credentials later suddenly has
897
+ * evidence. The declaration must not be quietly reversed by the first
898
+ * successful walk — the operator has to be the one to change it.
899
+ */
900
+ expect(
901
+ resolveDeviceRole("router", {
902
+ sysDescr: "Cisco IOS Software, Catalyst 2960X switch",
903
+ deviceModel: "WS-C2960X-48FPD-L",
904
+ }),
905
+ ).toBe("router");
906
+ });
907
+ });
908
+
909
+ describe("resolveDeviceRole — determinism and totality", () => {
910
+ test("the order the signal fields were assigned in does not change the answer", () => {
911
+ /*
912
+ * The builder assembles these objects field by field from several
913
+ * sources, so the key insertion order differs between call sites. It
914
+ * must not be observable.
915
+ */
916
+ const oneOrder: DeviceRoleSignals = {
917
+ name: "idf2-sw-3",
918
+ sysDescr: "Cisco IOS Software, Catalyst 2960X switch",
919
+ vendor: "Cisco Systems",
920
+ deviceModel: "WS-C2960X-48FPD-L",
921
+ };
922
+ const anotherOrder: DeviceRoleSignals = {
923
+ deviceModel: "WS-C2960X-48FPD-L",
924
+ vendor: "Cisco Systems",
925
+ sysDescr: "Cisco IOS Software, Catalyst 2960X switch",
926
+ name: "idf2-sw-3",
927
+ };
928
+
929
+ expect(resolveDeviceRole(undefined, oneOrder)).toBe(
930
+ resolveDeviceRole(undefined, anotherOrder),
931
+ );
932
+ expect(resolveDeviceRole("camera", oneOrder)).toBe(
933
+ resolveDeviceRole("camera", anotherOrder),
934
+ );
935
+ });
936
+
937
+ test("resolving does not mutate the signals it was handed", () => {
938
+ /*
939
+ * The same signals object is read again when the node is rendered, so a
940
+ * normalisation written back in place would make the second read differ
941
+ * from the first.
942
+ */
943
+ const signals: DeviceRoleSignals = { ...CATALYST_SWITCH_SIGNALS };
944
+ resolveDeviceRole("camera", signals);
945
+ resolveDeviceRole(undefined, signals);
946
+
947
+ expect(signals).toEqual(CATALYST_SWITCH_SIGNALS);
948
+ });
949
+
950
+ test("resolving the same device repeatedly, interleaved with others, is stable", () => {
951
+ const overrides: Array<string | undefined | null> = [
952
+ "camera",
953
+ undefined,
954
+ "unknown",
955
+ null,
956
+ "camera",
957
+ "banana",
958
+ ];
959
+ const firstPass: Array<NetworkTopologyDeviceRole> = overrides.map(
960
+ (override: string | undefined | null) => {
961
+ return resolveDeviceRole(override, CATALYST_SWITCH_SIGNALS);
962
+ },
963
+ );
964
+ const secondPass: Array<NetworkTopologyDeviceRole> = [...overrides]
965
+ .reverse()
966
+ .map((override: string | undefined | null) => {
967
+ return resolveDeviceRole(override, CATALYST_SWITCH_SIGNALS);
968
+ });
969
+
970
+ expect(firstPass).toEqual([
971
+ "camera",
972
+ "switch",
973
+ "switch",
974
+ "switch",
975
+ "camera",
976
+ "switch",
977
+ ]);
978
+ expect(secondPass).toEqual([...firstPass].reverse());
979
+ });
980
+
981
+ test("it is total: every override and signal combination yields a known role", () => {
982
+ /*
983
+ * resolveDeviceRole feeds NetworkTopologyNode.role, which the map uses
984
+ * to pick a shape and a legend entry, so it can never hand back
985
+ * undefined or a string outside the union.
986
+ */
987
+ const knownRoles: Array<string> = Object.keys(DEVICE_ROLE_LABELS);
988
+ const overrides: Array<string | undefined | null> = [
989
+ undefined,
990
+ null,
991
+ "",
992
+ " ",
993
+ "unknown",
994
+ "banana",
995
+ "switch",
996
+ "loadBalancer",
997
+ "WIRELESSACCESSPOINT",
998
+ ];
999
+ const bundles: Array<DeviceRoleSignals> = [
1000
+ {},
1001
+ { name: "device-42" },
1002
+ CATALYST_SWITCH_SIGNALS,
1003
+ { sysDescr: "Linux app-3 5.4.0-91-generic x86_64", name: "app-3" },
1004
+ ];
1005
+
1006
+ for (const override of overrides) {
1007
+ for (const bundle of bundles) {
1008
+ const role: NetworkTopologyDeviceRole = resolveDeviceRole(
1009
+ override,
1010
+ bundle,
1011
+ );
1012
+ expect(knownRoles).toContain(role);
1013
+ }
1014
+ }
1015
+ });
1016
+ });