@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.
- 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/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
- package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
- package/Server/Utils/VM/VMRunner.ts +693 -874
- 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/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
- package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -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/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
- 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/Types/Workflow/Components/Conditions/IfElse.js +8 -0
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.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/Server/Utils/VM/VMRunner.js +402 -525
- package/build/dist/Server/Utils/VM/VMRunner.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
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
import NetworkDeviceLinkService from "../../../Server/Services/NetworkDeviceLinkService";
|
|
2
|
+
import NetworkDeviceService from "../../../Server/Services/NetworkDeviceService";
|
|
3
|
+
import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
|
|
4
|
+
import NetworkDeviceLink from "../../../Models/DatabaseModels/NetworkDeviceLink";
|
|
5
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
6
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
7
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
8
|
+
import UpdateBy from "../../../Server/Types/Database/UpdateBy";
|
|
9
|
+
import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
|
|
10
|
+
import { describe, expect, it, afterEach } from "@jest/globals";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Contract under test - the declared-parent invariant on NetworkDeviceLink
|
|
14
|
+
* (OneUptime/oneuptime#3192):
|
|
15
|
+
*
|
|
16
|
+
* a link's parentDeviceId, when set, must name one of the two devices that
|
|
17
|
+
* link connects.
|
|
18
|
+
*
|
|
19
|
+
* A parent that is not an end is not a statement about this link at all. The
|
|
20
|
+
* topology builder ignores it (buildTopology only stamps edge.parentNodeId
|
|
21
|
+
* when parentDeviceId matches an end), so the operator would declare a
|
|
22
|
+
* hierarchy, save it successfully, and watch the map not move. Hence both
|
|
23
|
+
* write paths refuse it up front rather than storing a row that means nothing.
|
|
24
|
+
*
|
|
25
|
+
* onBeforeUpdate carries the harder half of the invariant, because an update
|
|
26
|
+
* can break it from the other side: leave the parent alone and move an END out
|
|
27
|
+
* from under it. So the post-update state is what gets validated, assembled
|
|
28
|
+
* from the payload where it speaks and from the stored row where it does not -
|
|
29
|
+
* for every row the update's query matches, not just one.
|
|
30
|
+
*
|
|
31
|
+
* All of it runs against stubbed reads. No database.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
35
|
+
"22222222-2222-4222-8222-222222222222",
|
|
36
|
+
);
|
|
37
|
+
const DEVICE_A_ID: ObjectID = new ObjectID(
|
|
38
|
+
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
39
|
+
);
|
|
40
|
+
const DEVICE_B_ID: ObjectID = new ObjectID(
|
|
41
|
+
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
42
|
+
);
|
|
43
|
+
// The third wheel: a real device in the project that this link does not touch.
|
|
44
|
+
const DEVICE_C_ID: ObjectID = new ObjectID(
|
|
45
|
+
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
46
|
+
);
|
|
47
|
+
const LINK_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
|
|
48
|
+
const OTHER_LINK_ID: ObjectID = new ObjectID(
|
|
49
|
+
"33333333-3333-4333-8333-333333333333",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const PARENT_NOT_AN_END_MESSAGE: string =
|
|
53
|
+
"The parent device must be one of the two devices this link connects.";
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* onBeforeCreate reads both ends back to prove they exist and are in the
|
|
57
|
+
* project. Every create test needs that read to succeed before the parent
|
|
58
|
+
* check is even reached, so it gets its own helper.
|
|
59
|
+
*/
|
|
60
|
+
function mockBothEndsExist(): jest.SpyInstance {
|
|
61
|
+
return jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([
|
|
62
|
+
{
|
|
63
|
+
_id: DEVICE_A_ID.toString(),
|
|
64
|
+
id: DEVICE_A_ID,
|
|
65
|
+
} as unknown as NetworkDevice,
|
|
66
|
+
{
|
|
67
|
+
_id: DEVICE_B_ID.toString(),
|
|
68
|
+
id: DEVICE_B_ID,
|
|
69
|
+
} as unknown as NetworkDevice,
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeCreateBy(
|
|
74
|
+
data: Record<string, unknown>,
|
|
75
|
+
): CreateBy<NetworkDeviceLink> {
|
|
76
|
+
return {
|
|
77
|
+
data: {
|
|
78
|
+
projectId: PROJECT_ID,
|
|
79
|
+
...data,
|
|
80
|
+
} as unknown as NetworkDeviceLink,
|
|
81
|
+
props: { isRoot: true },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function storedLink(data: {
|
|
86
|
+
_id?: ObjectID;
|
|
87
|
+
fromDeviceId?: ObjectID | undefined;
|
|
88
|
+
toDeviceId?: ObjectID | undefined;
|
|
89
|
+
parentDeviceId?: ObjectID | undefined;
|
|
90
|
+
}): NetworkDeviceLink {
|
|
91
|
+
return {
|
|
92
|
+
_id: (data._id || LINK_ID).toString(),
|
|
93
|
+
id: data._id || LINK_ID,
|
|
94
|
+
/*
|
|
95
|
+
* Carried because moving an end re-checks that the new device belongs
|
|
96
|
+
* to the link's project, exactly as create does — the FK is global, so
|
|
97
|
+
* without that check an update could re-point an end at another
|
|
98
|
+
* project's device and surface its name on this project's map.
|
|
99
|
+
*/
|
|
100
|
+
projectId: PROJECT_ID,
|
|
101
|
+
fromDeviceId: data.fromDeviceId,
|
|
102
|
+
toDeviceId: data.toDeviceId,
|
|
103
|
+
parentDeviceId: data.parentDeviceId,
|
|
104
|
+
} as unknown as NetworkDeviceLink;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* The device read that the moved-end tenancy check performs. Returns all
|
|
109
|
+
* three project devices, so any end move is legitimate unless a test
|
|
110
|
+
* deliberately overrides it — which the tenancy tests do.
|
|
111
|
+
*/
|
|
112
|
+
function mockProjectDevicesExist(): jest.SpyInstance {
|
|
113
|
+
return jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue(
|
|
114
|
+
[DEVICE_A_ID, DEVICE_B_ID, DEVICE_C_ID].map((id: ObjectID) => {
|
|
115
|
+
return {
|
|
116
|
+
_id: id.toString(),
|
|
117
|
+
id: id,
|
|
118
|
+
} as unknown as NetworkDevice;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function makeUpdateBy(
|
|
124
|
+
data: Record<string, unknown>,
|
|
125
|
+
): UpdateBy<NetworkDeviceLink> {
|
|
126
|
+
return {
|
|
127
|
+
query: { _id: LINK_ID.toString() },
|
|
128
|
+
data: data,
|
|
129
|
+
props: { isRoot: true },
|
|
130
|
+
} as unknown as UpdateBy<NetworkDeviceLink>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The stored rows an update is validated against.
|
|
134
|
+
function mockStoredLinks(links: Array<NetworkDeviceLink>): jest.SpyInstance {
|
|
135
|
+
/*
|
|
136
|
+
* Installed alongside, so a test about the PARENT invariant is never
|
|
137
|
+
* incidentally failed by the tenancy check on an end it moved to set
|
|
138
|
+
* the scene. Tests about tenancy itself re-stub this.
|
|
139
|
+
*/
|
|
140
|
+
mockProjectDevicesExist();
|
|
141
|
+
return jest
|
|
142
|
+
.spyOn(NetworkDeviceLinkService, "findBy")
|
|
143
|
+
.mockResolvedValue(links);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe("NetworkDeviceLinkService.onBeforeCreate (declared parent)", () => {
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
jest.restoreAllMocks();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("accepts a parent that is the link's from-device", async () => {
|
|
152
|
+
mockBothEndsExist();
|
|
153
|
+
|
|
154
|
+
const result: OnCreate<NetworkDeviceLink> = await (
|
|
155
|
+
NetworkDeviceLinkService as any
|
|
156
|
+
).onBeforeCreate(
|
|
157
|
+
makeCreateBy({
|
|
158
|
+
fromDeviceId: DEVICE_A_ID,
|
|
159
|
+
toDeviceId: DEVICE_B_ID,
|
|
160
|
+
parentDeviceId: DEVICE_A_ID,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result.carryForward).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("accepts a parent that is the link's to-device", async () => {
|
|
168
|
+
mockBothEndsExist();
|
|
169
|
+
|
|
170
|
+
const result: OnCreate<NetworkDeviceLink> = await (
|
|
171
|
+
NetworkDeviceLinkService as any
|
|
172
|
+
).onBeforeCreate(
|
|
173
|
+
makeCreateBy({
|
|
174
|
+
fromDeviceId: DEVICE_A_ID,
|
|
175
|
+
toDeviceId: DEVICE_B_ID,
|
|
176
|
+
parentDeviceId: DEVICE_B_ID,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(result.carryForward).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* The common case, and the one that must stay cheap: most links are peers
|
|
185
|
+
* (two access switches on the same trunk), and declaring nothing is how an
|
|
186
|
+
* operator says "these are siblings, let the layout decide".
|
|
187
|
+
*/
|
|
188
|
+
it("accepts a link with no declared parent at all", async () => {
|
|
189
|
+
mockBothEndsExist();
|
|
190
|
+
|
|
191
|
+
const result: OnCreate<NetworkDeviceLink> = await (
|
|
192
|
+
NetworkDeviceLinkService as any
|
|
193
|
+
).onBeforeCreate(
|
|
194
|
+
makeCreateBy({
|
|
195
|
+
fromDeviceId: DEVICE_A_ID,
|
|
196
|
+
toDeviceId: DEVICE_B_ID,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(result.carryForward).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("rejects a parent naming a third device, and says why", async () => {
|
|
204
|
+
mockBothEndsExist();
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
208
|
+
makeCreateBy({
|
|
209
|
+
fromDeviceId: DEVICE_A_ID,
|
|
210
|
+
toDeviceId: DEVICE_B_ID,
|
|
211
|
+
parentDeviceId: DEVICE_C_ID,
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
).rejects.toThrow(BadDataException);
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
* The message is the whole user experience of this guard - it is what the
|
|
218
|
+
* dashboard renders when the save fails - so it has to name the constraint
|
|
219
|
+
* rather than say "bad data".
|
|
220
|
+
*/
|
|
221
|
+
await expect(
|
|
222
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
223
|
+
makeCreateBy({
|
|
224
|
+
fromDeviceId: DEVICE_A_ID,
|
|
225
|
+
toDeviceId: DEVICE_B_ID,
|
|
226
|
+
parentDeviceId: DEVICE_C_ID,
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* The dashboard's link form posts the RELATION (`{ parentDevice: { _id } }`),
|
|
234
|
+
* never the FK column, because DatabaseBaseModel serialises the entity
|
|
235
|
+
* column it was handed. A guard that read only `parentDeviceId` would wave
|
|
236
|
+
* every UI-authored declaration straight through.
|
|
237
|
+
*/
|
|
238
|
+
it("rejects a third-device parent posted as the `parentDevice` relation", async () => {
|
|
239
|
+
mockBothEndsExist();
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
243
|
+
makeCreateBy({
|
|
244
|
+
fromDevice: { _id: DEVICE_A_ID.toString() },
|
|
245
|
+
toDevice: { _id: DEVICE_B_ID.toString() },
|
|
246
|
+
parentDevice: { _id: DEVICE_C_ID.toString() },
|
|
247
|
+
}),
|
|
248
|
+
),
|
|
249
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("accepts a valid parent posted as the `parentDevice` relation", async () => {
|
|
253
|
+
mockBothEndsExist();
|
|
254
|
+
|
|
255
|
+
const result: OnCreate<NetworkDeviceLink> = await (
|
|
256
|
+
NetworkDeviceLinkService as any
|
|
257
|
+
).onBeforeCreate(
|
|
258
|
+
makeCreateBy({
|
|
259
|
+
fromDevice: { _id: DEVICE_A_ID.toString() },
|
|
260
|
+
toDevice: { _id: DEVICE_B_ID.toString() },
|
|
261
|
+
parentDevice: { _id: DEVICE_A_ID.toString() },
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(result.carryForward).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* Determinism: the two spellings are the same statement, so a payload that
|
|
270
|
+
* mixes them - column for the ends, relation for the parent, or the reverse
|
|
271
|
+
* - has to land on the same verdict as either pure form.
|
|
272
|
+
*/
|
|
273
|
+
it("reaches the same verdict whichever spelling each reference uses", async () => {
|
|
274
|
+
mockBothEndsExist();
|
|
275
|
+
|
|
276
|
+
const payloads: Array<Record<string, unknown>> = [
|
|
277
|
+
{
|
|
278
|
+
fromDeviceId: DEVICE_A_ID,
|
|
279
|
+
toDeviceId: DEVICE_B_ID,
|
|
280
|
+
parentDevice: { _id: DEVICE_C_ID.toString() },
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
fromDevice: { _id: DEVICE_A_ID.toString() },
|
|
284
|
+
toDevice: { _id: DEVICE_B_ID.toString() },
|
|
285
|
+
parentDeviceId: DEVICE_C_ID,
|
|
286
|
+
},
|
|
287
|
+
// Key order reversed: the reader must not depend on insertion order.
|
|
288
|
+
{
|
|
289
|
+
parentDeviceId: DEVICE_C_ID,
|
|
290
|
+
toDeviceId: DEVICE_B_ID,
|
|
291
|
+
fromDeviceId: DEVICE_A_ID,
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
for (const payload of payloads) {
|
|
296
|
+
await expect(
|
|
297
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(makeCreateBy(payload)),
|
|
298
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/*
|
|
303
|
+
* The parent check sits behind the existing guards on purpose: a link with
|
|
304
|
+
* one end missing, or both ends the same device, has no "two ends" for a
|
|
305
|
+
* parent to be one of. Those errors must stay the ones reported.
|
|
306
|
+
*/
|
|
307
|
+
it("still reports the self-link error before looking at the parent", async () => {
|
|
308
|
+
mockBothEndsExist();
|
|
309
|
+
|
|
310
|
+
await expect(
|
|
311
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
312
|
+
makeCreateBy({
|
|
313
|
+
fromDeviceId: DEVICE_A_ID,
|
|
314
|
+
toDeviceId: DEVICE_A_ID,
|
|
315
|
+
parentDeviceId: DEVICE_C_ID,
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
).rejects.toThrow("A device cannot be linked to itself.");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("NetworkDeviceLinkService.onBeforeUpdate (declared parent)", () => {
|
|
323
|
+
afterEach(() => {
|
|
324
|
+
jest.restoreAllMocks();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/*
|
|
328
|
+
* The whole reason the hook computes `touchesHierarchy` first. Link rows are
|
|
329
|
+
* rewritten by every port rename and every monitor rebind; making each of
|
|
330
|
+
* those pay for a LIMIT_MAX read of the matched rows would be a real cost
|
|
331
|
+
* for a guard that could not possibly fire.
|
|
332
|
+
*/
|
|
333
|
+
it("performs no read when the payload touches neither an end nor the parent", async () => {
|
|
334
|
+
const findBySpy: jest.SpyInstance = jest.spyOn(
|
|
335
|
+
NetworkDeviceLinkService,
|
|
336
|
+
"findBy",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
340
|
+
NetworkDeviceLinkService as any
|
|
341
|
+
).onBeforeUpdate(
|
|
342
|
+
makeUpdateBy({
|
|
343
|
+
fromPort: "Gi1/0/24",
|
|
344
|
+
description: "patch panel B",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
349
|
+
expect(result.carryForward).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("rejects setting the parent to a device the stored link does not touch", async () => {
|
|
353
|
+
mockStoredLinks([
|
|
354
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
await expect(
|
|
358
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
359
|
+
makeUpdateBy({ parentDeviceId: DEVICE_C_ID }),
|
|
360
|
+
),
|
|
361
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("accepts setting the parent to one of the stored link's own ends", async () => {
|
|
365
|
+
mockStoredLinks([
|
|
366
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
370
|
+
NetworkDeviceLinkService as any
|
|
371
|
+
).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_B_ID }));
|
|
372
|
+
|
|
373
|
+
expect(result.carryForward).toBeNull();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("rejects a third-device parent posted as the `parentDevice` relation", async () => {
|
|
377
|
+
mockStoredLinks([
|
|
378
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
383
|
+
makeUpdateBy({ parentDevice: { _id: DEVICE_C_ID.toString() } }),
|
|
384
|
+
),
|
|
385
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/*
|
|
389
|
+
* The non-obvious half of the invariant. Nobody touched the parent here -
|
|
390
|
+
* the payload only re-points an end - but A->B with parent A becomes C->B,
|
|
391
|
+
* and A is suddenly a device this link has never heard of. Checking only the
|
|
392
|
+
* written parent would let this through and leave a stored row that the
|
|
393
|
+
* topology builder silently ignores forever.
|
|
394
|
+
*/
|
|
395
|
+
it("rejects moving an end out from under an already-declared parent", async () => {
|
|
396
|
+
mockStoredLinks([
|
|
397
|
+
storedLink({
|
|
398
|
+
fromDeviceId: DEVICE_A_ID,
|
|
399
|
+
toDeviceId: DEVICE_B_ID,
|
|
400
|
+
parentDeviceId: DEVICE_A_ID,
|
|
401
|
+
}),
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
await expect(
|
|
405
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
406
|
+
makeUpdateBy({ fromDeviceId: DEVICE_C_ID }),
|
|
407
|
+
),
|
|
408
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("rejects moving an end out from under a parent given as the `fromDevice` relation", async () => {
|
|
412
|
+
mockStoredLinks([
|
|
413
|
+
storedLink({
|
|
414
|
+
fromDeviceId: DEVICE_A_ID,
|
|
415
|
+
toDeviceId: DEVICE_B_ID,
|
|
416
|
+
parentDeviceId: DEVICE_A_ID,
|
|
417
|
+
}),
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
await expect(
|
|
421
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
422
|
+
makeUpdateBy({ fromDevice: { _id: DEVICE_C_ID.toString() } }),
|
|
423
|
+
),
|
|
424
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
/*
|
|
428
|
+
* The other end may move freely as long as the declared parent survives it:
|
|
429
|
+
* A->B with parent A becomes A->C, and A is still an end. Re-patching a
|
|
430
|
+
* downlink to a different access switch is routine and must not be blocked.
|
|
431
|
+
*/
|
|
432
|
+
it("accepts moving the end that is NOT the declared parent", async () => {
|
|
433
|
+
mockStoredLinks([
|
|
434
|
+
storedLink({
|
|
435
|
+
fromDeviceId: DEVICE_A_ID,
|
|
436
|
+
toDeviceId: DEVICE_B_ID,
|
|
437
|
+
parentDeviceId: DEVICE_A_ID,
|
|
438
|
+
}),
|
|
439
|
+
]);
|
|
440
|
+
|
|
441
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
442
|
+
NetworkDeviceLinkService as any
|
|
443
|
+
).onBeforeUpdate(makeUpdateBy({ toDeviceId: DEVICE_C_ID }));
|
|
444
|
+
|
|
445
|
+
expect(result.carryForward).toBeNull();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Moving an end AND re-declaring the parent onto the new end in one write.
|
|
449
|
+
it("accepts moving an end when the payload re-points the parent at the same time", async () => {
|
|
450
|
+
mockStoredLinks([
|
|
451
|
+
storedLink({
|
|
452
|
+
fromDeviceId: DEVICE_A_ID,
|
|
453
|
+
toDeviceId: DEVICE_B_ID,
|
|
454
|
+
parentDeviceId: DEVICE_A_ID,
|
|
455
|
+
}),
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
459
|
+
NetworkDeviceLinkService as any
|
|
460
|
+
).onBeforeUpdate(
|
|
461
|
+
makeUpdateBy({
|
|
462
|
+
fromDeviceId: DEVICE_C_ID,
|
|
463
|
+
parentDeviceId: DEVICE_C_ID,
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(result.carryForward).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
/*
|
|
471
|
+
* The isParentWritten branch, which is the subtle one. Demoting a hierarchy
|
|
472
|
+
* back to a peer link means writing the parent key with an empty value -
|
|
473
|
+
* and RelationIdUtil reads that as null, exactly like "not written at all".
|
|
474
|
+
* If the hook decided on the VALUE it would fall back to the stored parent
|
|
475
|
+
* A, find it absent from the new C->B ends, and refuse the very write that
|
|
476
|
+
* was removing the offending declaration. The KEY's presence is what
|
|
477
|
+
* decides.
|
|
478
|
+
*/
|
|
479
|
+
it("accepts clearing the parent with null even while an end moves away from it", async () => {
|
|
480
|
+
mockStoredLinks([
|
|
481
|
+
storedLink({
|
|
482
|
+
fromDeviceId: DEVICE_A_ID,
|
|
483
|
+
toDeviceId: DEVICE_B_ID,
|
|
484
|
+
parentDeviceId: DEVICE_A_ID,
|
|
485
|
+
}),
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
489
|
+
NetworkDeviceLinkService as any
|
|
490
|
+
).onBeforeUpdate(
|
|
491
|
+
makeUpdateBy({
|
|
492
|
+
fromDeviceId: DEVICE_C_ID,
|
|
493
|
+
parentDeviceId: null,
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
expect(result.carryForward).toBeNull();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Same branch, reached through the other empty values the UI can send.
|
|
501
|
+
it("treats empty-string and a null relation as clearing the parent too", async () => {
|
|
502
|
+
const emptyValues: Array<Record<string, unknown>> = [
|
|
503
|
+
{ fromDeviceId: DEVICE_C_ID, parentDeviceId: "" },
|
|
504
|
+
{ fromDeviceId: DEVICE_C_ID, parentDevice: null },
|
|
505
|
+
];
|
|
506
|
+
|
|
507
|
+
for (const data of emptyValues) {
|
|
508
|
+
mockStoredLinks([
|
|
509
|
+
storedLink({
|
|
510
|
+
fromDeviceId: DEVICE_A_ID,
|
|
511
|
+
toDeviceId: DEVICE_B_ID,
|
|
512
|
+
parentDeviceId: DEVICE_A_ID,
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
517
|
+
NetworkDeviceLinkService as any
|
|
518
|
+
).onBeforeUpdate(makeUpdateBy(data));
|
|
519
|
+
|
|
520
|
+
expect(result.carryForward).toBeNull();
|
|
521
|
+
jest.restoreAllMocks();
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
/*
|
|
526
|
+
* undefined is NOT one of them, and the difference is not pedantry.
|
|
527
|
+
* TypeORM drops undefined-valued keys before building the SET list, so
|
|
528
|
+
* such a key writes nothing at all. Reading it as "the parent is being
|
|
529
|
+
* cleared" makes this hook validate a row that will never exist: it
|
|
530
|
+
* would wave the write through, and the stored parent would survive on
|
|
531
|
+
* a link whose end had just moved out from under it — precisely the
|
|
532
|
+
* state the hook exists to prevent.
|
|
533
|
+
*/
|
|
534
|
+
it("refuses a moved end when a present-but-undefined parent key would leave the stored parent stranded", async () => {
|
|
535
|
+
mockStoredLinks([
|
|
536
|
+
storedLink({
|
|
537
|
+
fromDeviceId: DEVICE_A_ID,
|
|
538
|
+
toDeviceId: DEVICE_B_ID,
|
|
539
|
+
parentDeviceId: DEVICE_A_ID,
|
|
540
|
+
}),
|
|
541
|
+
]);
|
|
542
|
+
|
|
543
|
+
await expect(
|
|
544
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
545
|
+
makeUpdateBy({
|
|
546
|
+
fromDeviceId: DEVICE_C_ID,
|
|
547
|
+
parentDeviceId: undefined,
|
|
548
|
+
}),
|
|
549
|
+
),
|
|
550
|
+
).rejects.toThrow(
|
|
551
|
+
"The parent device must be one of the two devices this link connects.",
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// And a payload of nothing BUT undefined writes nothing, so it reads nothing.
|
|
556
|
+
it("takes the early return when every hierarchy key present holds undefined", async () => {
|
|
557
|
+
const findBySpy: jest.SpyInstance = mockStoredLinks([]);
|
|
558
|
+
|
|
559
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
560
|
+
NetworkDeviceLinkService as any
|
|
561
|
+
).onBeforeUpdate(
|
|
562
|
+
makeUpdateBy({
|
|
563
|
+
fromDeviceId: undefined,
|
|
564
|
+
parentDeviceId: undefined,
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
expect(result.carryForward).toBeNull();
|
|
569
|
+
expect(findBySpy).not.toHaveBeenCalled();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
/*
|
|
573
|
+
* Clearing the parent still counts as touching the hierarchy, so it must not
|
|
574
|
+
* take the early return - the same write can be moving an end, and that end
|
|
575
|
+
* move is what the read exists to check.
|
|
576
|
+
*/
|
|
577
|
+
it("still reads the stored rows when the only hierarchy key present clears the parent", async () => {
|
|
578
|
+
const findBySpy: jest.SpyInstance = mockStoredLinks([
|
|
579
|
+
storedLink({
|
|
580
|
+
fromDeviceId: DEVICE_A_ID,
|
|
581
|
+
toDeviceId: DEVICE_B_ID,
|
|
582
|
+
parentDeviceId: DEVICE_A_ID,
|
|
583
|
+
}),
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
await (NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
587
|
+
makeUpdateBy({ parentDeviceId: null }),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
expect(findBySpy).toHaveBeenCalledTimes(1);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
/*
|
|
594
|
+
* An update matches a QUERY. "Set parent = A on every link into rack 3" can
|
|
595
|
+
* land on rows with completely different ends, and only some of them touch
|
|
596
|
+
* A. Fixing the ones that fit while leaving the rest self-contradicting
|
|
597
|
+
* would be far harder to notice than a refusal, so one bad row rejects the
|
|
598
|
+
* whole write.
|
|
599
|
+
*/
|
|
600
|
+
it("validates every matched row and rejects the write when any one of them fails", async () => {
|
|
601
|
+
mockStoredLinks([
|
|
602
|
+
storedLink({
|
|
603
|
+
_id: LINK_ID,
|
|
604
|
+
fromDeviceId: DEVICE_A_ID,
|
|
605
|
+
toDeviceId: DEVICE_B_ID,
|
|
606
|
+
}),
|
|
607
|
+
storedLink({
|
|
608
|
+
_id: OTHER_LINK_ID,
|
|
609
|
+
fromDeviceId: DEVICE_B_ID,
|
|
610
|
+
toDeviceId: DEVICE_C_ID,
|
|
611
|
+
}),
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
await expect(
|
|
615
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
616
|
+
makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
|
|
617
|
+
),
|
|
618
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/*
|
|
622
|
+
* Determinism: the read has no ORDER BY, so which row Postgres hands back
|
|
623
|
+
* first is not something the caller controls. The verdict must not depend on
|
|
624
|
+
* it - a rejection has to be a rejection with the bad row first or last.
|
|
625
|
+
*/
|
|
626
|
+
it("rejects the same multi-row write regardless of the order the rows come back", async () => {
|
|
627
|
+
const goodRow: NetworkDeviceLink = storedLink({
|
|
628
|
+
_id: LINK_ID,
|
|
629
|
+
fromDeviceId: DEVICE_A_ID,
|
|
630
|
+
toDeviceId: DEVICE_B_ID,
|
|
631
|
+
});
|
|
632
|
+
const badRow: NetworkDeviceLink = storedLink({
|
|
633
|
+
_id: OTHER_LINK_ID,
|
|
634
|
+
fromDeviceId: DEVICE_B_ID,
|
|
635
|
+
toDeviceId: DEVICE_C_ID,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
for (const rows of [
|
|
639
|
+
[goodRow, badRow],
|
|
640
|
+
[badRow, goodRow],
|
|
641
|
+
]) {
|
|
642
|
+
mockStoredLinks(rows);
|
|
643
|
+
|
|
644
|
+
await expect(
|
|
645
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
646
|
+
makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
|
|
647
|
+
),
|
|
648
|
+
).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
|
|
649
|
+
|
|
650
|
+
jest.restoreAllMocks();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("accepts a multi-row write when the parent is an end of every matched row", async () => {
|
|
655
|
+
mockStoredLinks([
|
|
656
|
+
storedLink({
|
|
657
|
+
_id: LINK_ID,
|
|
658
|
+
fromDeviceId: DEVICE_A_ID,
|
|
659
|
+
toDeviceId: DEVICE_B_ID,
|
|
660
|
+
}),
|
|
661
|
+
storedLink({
|
|
662
|
+
_id: OTHER_LINK_ID,
|
|
663
|
+
fromDeviceId: DEVICE_C_ID,
|
|
664
|
+
toDeviceId: DEVICE_A_ID,
|
|
665
|
+
}),
|
|
666
|
+
]);
|
|
667
|
+
|
|
668
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
669
|
+
NetworkDeviceLinkService as any
|
|
670
|
+
).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_A_ID }));
|
|
671
|
+
|
|
672
|
+
expect(result.carryForward).toBeNull();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
/*
|
|
676
|
+
* The self-link guard has to hold on the update path too: a link the map
|
|
677
|
+
* would drop as a self-edge is invisible and permanently confusing, whether
|
|
678
|
+
* it was created that way or edited into that shape.
|
|
679
|
+
*/
|
|
680
|
+
it("rejects an update that would point both ends at the same device", async () => {
|
|
681
|
+
mockStoredLinks([
|
|
682
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
683
|
+
]);
|
|
684
|
+
|
|
685
|
+
await expect(
|
|
686
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
687
|
+
makeUpdateBy({ toDeviceId: DEVICE_A_ID }),
|
|
688
|
+
),
|
|
689
|
+
).rejects.toThrow("A device cannot be linked to itself.");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("rejects an update that points both written ends at the same device", async () => {
|
|
693
|
+
mockStoredLinks([
|
|
694
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
695
|
+
]);
|
|
696
|
+
|
|
697
|
+
await expect(
|
|
698
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
699
|
+
makeUpdateBy({
|
|
700
|
+
fromDeviceId: DEVICE_C_ID,
|
|
701
|
+
toDeviceId: DEVICE_C_ID,
|
|
702
|
+
}),
|
|
703
|
+
),
|
|
704
|
+
).rejects.toThrow("A device cannot be linked to itself.");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
/*
|
|
708
|
+
* A row mid-migration - written before both ends were mandatory - has no two
|
|
709
|
+
* ends for a parent to be one of, so there is no invariant to check and the
|
|
710
|
+
* hook must not invent one and block unrelated writes.
|
|
711
|
+
*/
|
|
712
|
+
it("skips a stored row that is missing an end rather than failing the write", async () => {
|
|
713
|
+
mockStoredLinks([
|
|
714
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: undefined }),
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
718
|
+
NetworkDeviceLinkService as any
|
|
719
|
+
).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_C_ID }));
|
|
720
|
+
|
|
721
|
+
expect(result.carryForward).toBeNull();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// Nothing matched, nothing to contradict.
|
|
725
|
+
it("accepts a write whose query matched no rows", async () => {
|
|
726
|
+
mockStoredLinks([]);
|
|
727
|
+
|
|
728
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
729
|
+
NetworkDeviceLinkService as any
|
|
730
|
+
).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_C_ID }));
|
|
731
|
+
|
|
732
|
+
expect(result.carryForward).toBeNull();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
/*
|
|
736
|
+
* Determinism again, on the payload side: the hook reads keys by name, so
|
|
737
|
+
* shuffling them (as any JSON body may arrive shuffled) must not move the
|
|
738
|
+
* verdict.
|
|
739
|
+
*/
|
|
740
|
+
it("reaches the same verdict whatever order the payload keys arrive in", async () => {
|
|
741
|
+
const shuffles: Array<Record<string, unknown>> = [
|
|
742
|
+
{ fromDeviceId: DEVICE_C_ID, parentDeviceId: DEVICE_B_ID },
|
|
743
|
+
{ parentDeviceId: DEVICE_B_ID, fromDeviceId: DEVICE_C_ID },
|
|
744
|
+
];
|
|
745
|
+
|
|
746
|
+
for (const data of shuffles) {
|
|
747
|
+
mockStoredLinks([
|
|
748
|
+
storedLink({
|
|
749
|
+
fromDeviceId: DEVICE_A_ID,
|
|
750
|
+
toDeviceId: DEVICE_B_ID,
|
|
751
|
+
parentDeviceId: DEVICE_A_ID,
|
|
752
|
+
}),
|
|
753
|
+
]);
|
|
754
|
+
|
|
755
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
756
|
+
NetworkDeviceLinkService as any
|
|
757
|
+
).onBeforeUpdate(makeUpdateBy(data));
|
|
758
|
+
|
|
759
|
+
expect(result.carryForward).toBeNull();
|
|
760
|
+
jest.restoreAllMocks();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
/*
|
|
765
|
+
* The hook validates; it does not rewrite. Anything it silently normalised
|
|
766
|
+
* into updateBy.data would be written to the row without the caller ever
|
|
767
|
+
* having asked for it.
|
|
768
|
+
*/
|
|
769
|
+
it("passes the caller's payload through untouched when the write is accepted", async () => {
|
|
770
|
+
mockStoredLinks([
|
|
771
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
772
|
+
]);
|
|
773
|
+
|
|
774
|
+
const updateBy: UpdateBy<NetworkDeviceLink> = makeUpdateBy({
|
|
775
|
+
parentDeviceId: DEVICE_A_ID,
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
779
|
+
NetworkDeviceLinkService as any
|
|
780
|
+
).onBeforeUpdate(updateBy);
|
|
781
|
+
|
|
782
|
+
expect(result.updateBy).toBe(updateBy);
|
|
783
|
+
expect(
|
|
784
|
+
Object.keys(result.updateBy.data as unknown as Record<string, unknown>),
|
|
785
|
+
).toEqual(["parentDeviceId"]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
/*
|
|
789
|
+
* The read runs as root so it can see the rows the caller is about to write,
|
|
790
|
+
* which means the caller's tenant has to be re-applied by hand - otherwise
|
|
791
|
+
* a project could be handed another project's links to validate against.
|
|
792
|
+
*/
|
|
793
|
+
it("scopes the validation read to the caller's project", async () => {
|
|
794
|
+
const findBySpy: jest.SpyInstance = mockStoredLinks([]);
|
|
795
|
+
|
|
796
|
+
await (NetworkDeviceLinkService as any).onBeforeUpdate({
|
|
797
|
+
query: { _id: LINK_ID.toString() },
|
|
798
|
+
data: { parentDeviceId: DEVICE_A_ID },
|
|
799
|
+
props: { tenantId: PROJECT_ID },
|
|
800
|
+
} as unknown as UpdateBy<NetworkDeviceLink>);
|
|
801
|
+
|
|
802
|
+
const query: any = findBySpy.mock.calls[0]![0].query;
|
|
803
|
+
expect(query._id).toBe(LINK_ID.toString());
|
|
804
|
+
expect(query.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
805
|
+
expect(findBySpy.mock.calls[0]![0].props.isRoot).toBe(true);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// The stored parent can only be honoured if the read actually fetches it.
|
|
809
|
+
it("selects the ends and the stored parent the check compares", async () => {
|
|
810
|
+
const findBySpy: jest.SpyInstance = mockStoredLinks([]);
|
|
811
|
+
|
|
812
|
+
await (NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
813
|
+
makeUpdateBy({ fromDeviceId: DEVICE_C_ID }),
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
const select: any = findBySpy.mock.calls[0]![0].select;
|
|
817
|
+
expect(select.fromDeviceId).toBe(true);
|
|
818
|
+
expect(select.toDeviceId).toBe(true);
|
|
819
|
+
expect(select.parentDeviceId).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
/*
|
|
824
|
+
* The tenancy half of the invariant.
|
|
825
|
+
*
|
|
826
|
+
* onBeforeCreate has always refused an end outside the caller's project,
|
|
827
|
+
* and says why in its own comment: the FK only requires the device row to
|
|
828
|
+
* exist, so without the check a tenant could draw a line to another
|
|
829
|
+
* project's device and read its name off the map. Updates never re-ran it,
|
|
830
|
+
* which left the guard one PUT away from being bypassed - and the declared
|
|
831
|
+
* parent would have inherited the hole, since a parent is only allowed to
|
|
832
|
+
* be an end.
|
|
833
|
+
*/
|
|
834
|
+
describe("NetworkDeviceLinkService.onBeforeUpdate (moved ends stay in the project)", () => {
|
|
835
|
+
const FOREIGN_DEVICE_ID: ObjectID = new ObjectID(
|
|
836
|
+
"ffffffff-ffff-4fff-8fff-ffffffffffff",
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
afterEach(() => {
|
|
840
|
+
jest.restoreAllMocks();
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("refuses an end moved to a device outside the link's project", async () => {
|
|
844
|
+
mockStoredLinks([
|
|
845
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
846
|
+
]);
|
|
847
|
+
// The project read finds nothing: that device is not in this project.
|
|
848
|
+
jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([]);
|
|
849
|
+
|
|
850
|
+
await expect(
|
|
851
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
852
|
+
makeUpdateBy({ toDeviceId: FOREIGN_DEVICE_ID }),
|
|
853
|
+
),
|
|
854
|
+
).rejects.toThrow("Both devices must exist and belong to this project.");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("refuses the same move made through the relation spelling", async () => {
|
|
858
|
+
mockStoredLinks([
|
|
859
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
860
|
+
]);
|
|
861
|
+
jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([]);
|
|
862
|
+
|
|
863
|
+
await expect(
|
|
864
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
865
|
+
makeUpdateBy({ toDevice: { _id: FOREIGN_DEVICE_ID.toString() } }),
|
|
866
|
+
),
|
|
867
|
+
).rejects.toThrow("Both devices must exist and belong to this project.");
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("scopes the device read to the link's own project", async () => {
|
|
871
|
+
mockStoredLinks([
|
|
872
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
873
|
+
]);
|
|
874
|
+
const deviceFindBy: jest.SpyInstance = mockProjectDevicesExist();
|
|
875
|
+
|
|
876
|
+
await (NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
877
|
+
makeUpdateBy({ toDeviceId: DEVICE_C_ID }),
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
expect(deviceFindBy).toHaveBeenCalled();
|
|
881
|
+
const query: any = deviceFindBy.mock.calls[0]![0].query;
|
|
882
|
+
expect(query.projectId.toString()).toBe(PROJECT_ID.toString());
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("accepts an end moved to another device in the same project", async () => {
|
|
886
|
+
mockStoredLinks([
|
|
887
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
888
|
+
]);
|
|
889
|
+
|
|
890
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
891
|
+
NetworkDeviceLinkService as any
|
|
892
|
+
).onBeforeUpdate(makeUpdateBy({ toDeviceId: DEVICE_C_ID }));
|
|
893
|
+
|
|
894
|
+
expect(result.carryForward).toBeNull();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
/*
|
|
898
|
+
* A count would be satisfied by two rows for one device. The check
|
|
899
|
+
* compares the ids it asked about against the ids it got back.
|
|
900
|
+
*/
|
|
901
|
+
it("is not fooled by a duplicate row standing in for a missing device", async () => {
|
|
902
|
+
mockStoredLinks([
|
|
903
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
904
|
+
]);
|
|
905
|
+
jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([
|
|
906
|
+
{
|
|
907
|
+
_id: DEVICE_C_ID.toString(),
|
|
908
|
+
id: DEVICE_C_ID,
|
|
909
|
+
} as unknown as NetworkDevice,
|
|
910
|
+
{
|
|
911
|
+
_id: DEVICE_C_ID.toString(),
|
|
912
|
+
id: DEVICE_C_ID,
|
|
913
|
+
} as unknown as NetworkDevice,
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
await expect(
|
|
917
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
918
|
+
makeUpdateBy({
|
|
919
|
+
fromDeviceId: DEVICE_C_ID,
|
|
920
|
+
toDeviceId: FOREIGN_DEVICE_ID,
|
|
921
|
+
}),
|
|
922
|
+
),
|
|
923
|
+
).rejects.toThrow("Both devices must exist and belong to this project.");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// No end moved, so there is nothing to re-check and nothing to read.
|
|
927
|
+
it("does not read devices when only the parent is written", async () => {
|
|
928
|
+
mockStoredLinks([
|
|
929
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
930
|
+
]);
|
|
931
|
+
const deviceFindBy: jest.SpyInstance = mockProjectDevicesExist();
|
|
932
|
+
|
|
933
|
+
await (NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
934
|
+
makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
expect(deviceFindBy).not.toHaveBeenCalled();
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
/*
|
|
942
|
+
* Two shapes an id can arrive in that a naive comparison gets wrong.
|
|
943
|
+
*/
|
|
944
|
+
describe("NetworkDeviceLinkService - how ids are compared", () => {
|
|
945
|
+
afterEach(() => {
|
|
946
|
+
jest.restoreAllMocks();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
/*
|
|
950
|
+
* These columns are Postgres `uuid`, where equality ignores case, and
|
|
951
|
+
* ObjectID.isValidUUID accepts either case - so a client may legitimately
|
|
952
|
+
* send an upper-case id for a row stored lower-case. A raw JS !== calls
|
|
953
|
+
* that a different device and refuses a parent that IS an end. Note the
|
|
954
|
+
* device-existence check could never disagree: it runs in Postgres.
|
|
955
|
+
*/
|
|
956
|
+
it("accepts a parent that names an end in a different case", async () => {
|
|
957
|
+
mockStoredLinks([
|
|
958
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
959
|
+
]);
|
|
960
|
+
|
|
961
|
+
const result: OnUpdate<NetworkDeviceLink> = await (
|
|
962
|
+
NetworkDeviceLinkService as any
|
|
963
|
+
).onBeforeUpdate(
|
|
964
|
+
makeUpdateBy({
|
|
965
|
+
parentDeviceId: new ObjectID(DEVICE_A_ID.toString().toUpperCase()),
|
|
966
|
+
}),
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
expect(result.carryForward).toBeNull();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("still catches a self-link written in mixed case", async () => {
|
|
973
|
+
mockBothEndsExist();
|
|
974
|
+
|
|
975
|
+
await expect(
|
|
976
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
977
|
+
makeCreateBy({
|
|
978
|
+
fromDeviceId: DEVICE_A_ID,
|
|
979
|
+
toDeviceId: new ObjectID(DEVICE_A_ID.toString().toUpperCase()),
|
|
980
|
+
}),
|
|
981
|
+
),
|
|
982
|
+
).rejects.toThrow("A device cannot be linked to itself.");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
/*
|
|
986
|
+
* UpdateBy.data is a QueryDeepPartialEntity, so a column may legally hold
|
|
987
|
+
* a raw SQL-expression function. RelationIdUtil reads one as null -
|
|
988
|
+
* indistinguishable from "cleared" - and the invariant would then be
|
|
989
|
+
* checked against a row bearing no relation to what gets written. Nothing
|
|
990
|
+
* writes this model that way today; refusing is what keeps that true.
|
|
991
|
+
*/
|
|
992
|
+
it("refuses an end set to a raw SQL expression rather than mis-validating it", async () => {
|
|
993
|
+
mockStoredLinks([
|
|
994
|
+
storedLink({
|
|
995
|
+
fromDeviceId: DEVICE_A_ID,
|
|
996
|
+
toDeviceId: DEVICE_B_ID,
|
|
997
|
+
parentDeviceId: DEVICE_B_ID,
|
|
998
|
+
}),
|
|
999
|
+
]);
|
|
1000
|
+
|
|
1001
|
+
await expect(
|
|
1002
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
1003
|
+
makeUpdateBy({
|
|
1004
|
+
toDeviceId: (): string => {
|
|
1005
|
+
return `'${DEVICE_C_ID.toString()}'`;
|
|
1006
|
+
},
|
|
1007
|
+
}),
|
|
1008
|
+
),
|
|
1009
|
+
).rejects.toThrow(BadDataException);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("refuses a parent set to a raw SQL expression", async () => {
|
|
1013
|
+
mockStoredLinks([
|
|
1014
|
+
storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
await expect(
|
|
1018
|
+
(NetworkDeviceLinkService as any).onBeforeUpdate(
|
|
1019
|
+
makeUpdateBy({
|
|
1020
|
+
parentDeviceId: (): string => {
|
|
1021
|
+
return `'${DEVICE_C_ID.toString()}'`;
|
|
1022
|
+
},
|
|
1023
|
+
}),
|
|
1024
|
+
),
|
|
1025
|
+
).rejects.toThrow(BadDataException);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("refuses the same on create", async () => {
|
|
1029
|
+
mockBothEndsExist();
|
|
1030
|
+
|
|
1031
|
+
await expect(
|
|
1032
|
+
(NetworkDeviceLinkService as any).onBeforeCreate(
|
|
1033
|
+
makeCreateBy({
|
|
1034
|
+
fromDeviceId: DEVICE_A_ID,
|
|
1035
|
+
toDeviceId: DEVICE_B_ID,
|
|
1036
|
+
parentDeviceId: (): string => {
|
|
1037
|
+
return `'${DEVICE_C_ID.toString()}'`;
|
|
1038
|
+
},
|
|
1039
|
+
}),
|
|
1040
|
+
),
|
|
1041
|
+
).rejects.toThrow(BadDataException);
|
|
1042
|
+
});
|
|
1043
|
+
});
|