@7365admin1/core 3.80.0 → 3.81.0
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/CHANGELOG.md +40 -0
- package/dist/index.d.ts +697 -4
- package/dist/index.js +3050 -2327
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1643 -960
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/robot-scope.e2e.test.mjs +57 -33
- package/test/robot-apps.test.mjs +154 -0
- package/test/robot-cloud.test.mjs +222 -0
- package/test/robot-permission.test.mjs +133 -0
- package/test/robot-tracking.test.mjs +231 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ROBOT_MODULE,
|
|
6
|
+
ROBOT_PERMISSIONS,
|
|
7
|
+
allowsRobotAction,
|
|
8
|
+
allowsRobotListing,
|
|
9
|
+
robotActionsFor,
|
|
10
|
+
} from "./.build/utils/robot-permission.util.mjs";
|
|
11
|
+
|
|
12
|
+
/* --------------------------------------------------------------------------
|
|
13
|
+
* Nothing on the server has ever enforced the five robot permissions - the two
|
|
14
|
+
* routes carry requireAuth and the estate check, so anybody who can reach an
|
|
15
|
+
* estate can register a robot into it. This is the missing half.
|
|
16
|
+
*
|
|
17
|
+
* The rule that needs the most care is the empty list. Seven365's own staff
|
|
18
|
+
* role is seeded with `permissions: []`, not `["*"]`, so enforcing an empty
|
|
19
|
+
* list would refuse the platform owner - while treating EVERY empty list as
|
|
20
|
+
* "everything" would hand each unconfigured client role the whole module.
|
|
21
|
+
* ----------------------------------------------------------------------- */
|
|
22
|
+
|
|
23
|
+
const ALL_ACTIONS = ["seeAll", "add", "update", "seeDetails", "delete"];
|
|
24
|
+
|
|
25
|
+
test("the permission strings are module:permission, on robot-mgmt", () => {
|
|
26
|
+
assert.equal(ROBOT_MODULE, "robot-mgmt");
|
|
27
|
+
assert.deepEqual(ROBOT_PERMISSIONS, {
|
|
28
|
+
seeAll: "robot-mgmt:see-all-robots",
|
|
29
|
+
add: "robot-mgmt:add-robot",
|
|
30
|
+
update: "robot-mgmt:update-robot",
|
|
31
|
+
seeDetails: "robot-mgmt:see-robot-details",
|
|
32
|
+
delete: "robot-mgmt:delete-robot",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------- the wildcard */
|
|
37
|
+
|
|
38
|
+
test('"*" allows every action', () => {
|
|
39
|
+
const actor = { permissions: ["*"], roleType: "client" };
|
|
40
|
+
for (const action of ALL_ACTIONS) {
|
|
41
|
+
assert.equal(allowsRobotAction(actor, action), true, action);
|
|
42
|
+
}
|
|
43
|
+
assert.deepEqual(robotActionsFor(actor), ALL_ACTIONS);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/* -------------------------------------------------- the Seven365 Super Admin */
|
|
47
|
+
|
|
48
|
+
test("an EMPTY list on a platform-admin role allows everything", () => {
|
|
49
|
+
// createDefaultUser() seeds Seven365 staff with permissions: [] - and the
|
|
50
|
+
// owner's requirement is that the Super Admin can do everything, everywhere.
|
|
51
|
+
const platformStaff = { permissions: [], roleType: "admin" };
|
|
52
|
+
for (const action of ALL_ACTIONS) {
|
|
53
|
+
assert.equal(allowsRobotAction(platformStaff, action), true, action);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty list on a CLIENT role allows NOTHING", () => {
|
|
58
|
+
// The other half of the rule. Widening it to every role would hand each
|
|
59
|
+
// unconfigured client role the whole module.
|
|
60
|
+
for (const roleType of ["client", "member", "", null, undefined]) {
|
|
61
|
+
const actor = { permissions: [], roleType };
|
|
62
|
+
for (const action of ALL_ACTIONS) {
|
|
63
|
+
assert.equal(
|
|
64
|
+
allowsRobotAction(actor, action),
|
|
65
|
+
false,
|
|
66
|
+
`${String(roleType)} / ${action}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a platform-admin role with a NON-empty list is enforced like any other", () => {
|
|
73
|
+
// Once somebody has ticked a module, the list means what it says - an admin
|
|
74
|
+
// role granted only "see" cannot delete.
|
|
75
|
+
const actor = {
|
|
76
|
+
permissions: ["robot-mgmt:see-all-robots"],
|
|
77
|
+
roleType: "admin",
|
|
78
|
+
};
|
|
79
|
+
assert.equal(allowsRobotAction(actor, "seeAll"), true);
|
|
80
|
+
assert.equal(allowsRobotAction(actor, "delete"), false);
|
|
81
|
+
assert.equal(allowsRobotAction(actor, "add"), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/* ------------------------------------------------------------ explicit grant */
|
|
85
|
+
|
|
86
|
+
test("a non-empty list grants exactly what it names", () => {
|
|
87
|
+
const actor = {
|
|
88
|
+
permissions: ["robot-mgmt:see-all-robots", "robot-mgmt:add-robot"],
|
|
89
|
+
roleType: "client",
|
|
90
|
+
};
|
|
91
|
+
assert.deepEqual(robotActionsFor(actor), ["seeAll", "add"]);
|
|
92
|
+
assert.equal(allowsRobotListing(actor), true);
|
|
93
|
+
assert.equal(allowsRobotAction(actor, "delete"), false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a permission from another module does not leak across", () => {
|
|
97
|
+
const actor = {
|
|
98
|
+
permissions: ["sites:manage-site-modules", "virtual-patrol:see-all-routes"],
|
|
99
|
+
roleType: "client",
|
|
100
|
+
};
|
|
101
|
+
assert.deepEqual(robotActionsFor(actor), []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the bare permission name without its module does not count", () => {
|
|
105
|
+
// "see-all-robots" is how the catalogue lists it; "robot-mgmt:see-all-robots"
|
|
106
|
+
// is what a role document holds. Accepting the short form would let a
|
|
107
|
+
// different module's identically-named permission through.
|
|
108
|
+
const actor = { permissions: ["see-all-robots"], roleType: "client" };
|
|
109
|
+
assert.equal(allowsRobotListing(actor), false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------------------- fails closed */
|
|
113
|
+
|
|
114
|
+
test("an unreadable actor is refused", () => {
|
|
115
|
+
for (const actor of [
|
|
116
|
+
null,
|
|
117
|
+
undefined,
|
|
118
|
+
{},
|
|
119
|
+
{ permissions: null },
|
|
120
|
+
{ permissions: "robot-mgmt:see-all-robots" },
|
|
121
|
+
{ permissions: 5 },
|
|
122
|
+
]) {
|
|
123
|
+
assert.equal(allowsRobotListing(actor), false, JSON.stringify(actor));
|
|
124
|
+
assert.deepEqual(robotActionsFor(actor), []);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an action that is not one of the five is refused, even with the wildcard", () => {
|
|
129
|
+
const superuser = { permissions: ["*"], roleType: "admin" };
|
|
130
|
+
for (const bogus of ["drive", "", null, undefined, "constructor", "toString"]) {
|
|
131
|
+
assert.equal(allowsRobotAction(superuser, bogus), false, String(bogus));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
TRACKING_IDLE_TIMEOUT_MS,
|
|
6
|
+
applyTrackingFrame,
|
|
7
|
+
createSseParser,
|
|
8
|
+
decodeTrackingFrame,
|
|
9
|
+
disconnectedState,
|
|
10
|
+
isAvailableForWork,
|
|
11
|
+
isTrackingStale,
|
|
12
|
+
trackingBackoffMs,
|
|
13
|
+
} from "./.build/utils/robot-tracking.util.mjs";
|
|
14
|
+
|
|
15
|
+
/* --------------------------------------------------------------------------
|
|
16
|
+
* What these guard.
|
|
17
|
+
*
|
|
18
|
+
* The tracking stream is the only thing that says where a robot is and whether
|
|
19
|
+
* it is working. It is also the only part of the integration with no safety net
|
|
20
|
+
* from the vendor: no heartbeat, no sequence number, no command id. So the
|
|
21
|
+
* failure modes worth testing are the quiet ones - an event split across two TCP
|
|
22
|
+
* reads, a connection that died while the robot happened to be idle, and a
|
|
23
|
+
* reconnect storm we cause ourselves.
|
|
24
|
+
* ----------------------------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
const frame = (o) => `data: ${JSON.stringify(o)}\n\n`;
|
|
27
|
+
const POSE = { status: 2, x: 0.203, y: -0.063, theta: 0.041 };
|
|
28
|
+
|
|
29
|
+
/* ------------------------------------------------------------ SSE parsing */
|
|
30
|
+
|
|
31
|
+
test("a whole event in one chunk decodes", () => {
|
|
32
|
+
const p = createSseParser();
|
|
33
|
+
const { events } = p.push(frame(POSE));
|
|
34
|
+
assert.equal(events.length, 1);
|
|
35
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an event split across chunks is reassembled, not lost", () => {
|
|
39
|
+
// The defect this exists for: splitting each TCP chunk on its own drops or
|
|
40
|
+
// corrupts whatever event straddles the boundary.
|
|
41
|
+
const whole = frame(POSE);
|
|
42
|
+
const p = createSseParser();
|
|
43
|
+
|
|
44
|
+
for (let cut = 1; cut < whole.length; cut++) {
|
|
45
|
+
const q = createSseParser();
|
|
46
|
+
const first = q.push(whole.slice(0, cut));
|
|
47
|
+
const second = q.push(whole.slice(cut));
|
|
48
|
+
const events = [...first.events, ...second.events];
|
|
49
|
+
|
|
50
|
+
assert.equal(events.length, 1, `split at ${cut}`);
|
|
51
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE, `split at ${cut}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// and the single-chunk case still behaves
|
|
55
|
+
assert.equal(p.push(whole).events.length, 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("several events in one chunk all decode, in order", () => {
|
|
59
|
+
const p = createSseParser();
|
|
60
|
+
const { events } = p.push(
|
|
61
|
+
frame({ ...POSE, x: 1 }) + frame({ ...POSE, x: 2 }) + frame({ ...POSE, x: 3 })
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
events.map((e) => JSON.parse(e.data).x),
|
|
65
|
+
[1, 2, 3]
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a comment line is keep-alive: activity, but not an event", () => {
|
|
70
|
+
const p = createSseParser();
|
|
71
|
+
const { events, sawActivity } = p.push(": keep-alive\n\n");
|
|
72
|
+
assert.equal(events.length, 0);
|
|
73
|
+
assert.equal(sawActivity, true, "the watchdog must count this as alive");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an incomplete event is held back until it completes", () => {
|
|
77
|
+
const p = createSseParser();
|
|
78
|
+
assert.equal(p.push('data: {"status": 1,').events.length, 0);
|
|
79
|
+
assert.equal(p.push('"x": 0, "y": 0,').events.length, 0);
|
|
80
|
+
|
|
81
|
+
const { events } = p.push('"theta": 0}\n\n');
|
|
82
|
+
assert.equal(events.length, 1);
|
|
83
|
+
assert.deepEqual(JSON.parse(events[0].data), {
|
|
84
|
+
status: 1,
|
|
85
|
+
x: 0,
|
|
86
|
+
y: 0,
|
|
87
|
+
theta: 0,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("CRLF framing decodes the same as LF", () => {
|
|
92
|
+
const p = createSseParser();
|
|
93
|
+
const { events } = p.push(`data: ${JSON.stringify(POSE)}\r\n\r\n`);
|
|
94
|
+
assert.equal(events.length, 1);
|
|
95
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("reset drops a half-received event so a reconnect cannot splice it", () => {
|
|
99
|
+
const p = createSseParser();
|
|
100
|
+
p.push('data: {"status": 1, "x": 0');
|
|
101
|
+
p.reset();
|
|
102
|
+
|
|
103
|
+
// Without the reset, this would join the dead socket's fragment and parse.
|
|
104
|
+
const { events } = p.push(frame(POSE));
|
|
105
|
+
assert.equal(events.length, 1);
|
|
106
|
+
assert.deepEqual(JSON.parse(events[0].data), POSE);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/* ---------------------------------------------------------- pose decoding */
|
|
110
|
+
|
|
111
|
+
test("a good frame decodes to a pose with its normalised mode", () => {
|
|
112
|
+
const pose = decodeTrackingFrame(JSON.stringify(POSE));
|
|
113
|
+
assert.deepEqual(pose, {
|
|
114
|
+
x: 0.203,
|
|
115
|
+
y: -0.063,
|
|
116
|
+
theta: 0.041,
|
|
117
|
+
statusFlag: 2,
|
|
118
|
+
mode: "WORKING",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a partial pose is rejected whole, never defaulted to the origin", () => {
|
|
123
|
+
// (0,0) is a real location on these maps, so a missing field must not become
|
|
124
|
+
// a position.
|
|
125
|
+
for (const missing of ["x", "y", "theta", "status"]) {
|
|
126
|
+
const o = { ...POSE };
|
|
127
|
+
delete o[missing];
|
|
128
|
+
assert.equal(decodeTrackingFrame(JSON.stringify(o)), null, `no ${missing}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a malformed frame returns null instead of throwing", () => {
|
|
133
|
+
// One bad frame must not tear down a connection that is otherwise healthy.
|
|
134
|
+
for (const bad of ['{"status":', "", "null", "[]", '"a string"', "{}"]) {
|
|
135
|
+
assert.equal(decodeTrackingFrame(bad), null, JSON.stringify(bad));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("non-numeric and non-finite coordinates are refused", () => {
|
|
140
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":"0","y":0,"theta":0}'), null);
|
|
141
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":null,"y":0,"theta":0}'), null);
|
|
142
|
+
// NaN and Infinity are not JSON literals, so they arrive as these:
|
|
143
|
+
assert.equal(decodeTrackingFrame('{"status":1,"x":1e999,"y":0,"theta":0}'), null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------- staleness */
|
|
147
|
+
|
|
148
|
+
test("silence past the idle timeout is stale", () => {
|
|
149
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS - 1), false);
|
|
150
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS), true);
|
|
151
|
+
assert.equal(isTrackingStale(0, TRACKING_IDLE_TIMEOUT_MS + 5_000), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a robot whose stream died while IDLE is NOT offered work", () => {
|
|
155
|
+
// The branch that matters. Holding the last known mode would leave this robot
|
|
156
|
+
// advertised as ready forever, and the dispatcher would keep sending it jobs
|
|
157
|
+
// nothing is listening for.
|
|
158
|
+
const idle = applyTrackingFrame(
|
|
159
|
+
disconnectedState("CASDF03456", 0),
|
|
160
|
+
decodeTrackingFrame(JSON.stringify({ ...POSE, status: 1 })),
|
|
161
|
+
0
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
assert.equal(isAvailableForWork(idle, 0), true, "fresh and idle");
|
|
165
|
+
assert.equal(
|
|
166
|
+
isAvailableForWork(idle, TRACKING_IDLE_TIMEOUT_MS),
|
|
167
|
+
false,
|
|
168
|
+
"same state, stream gone silent"
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a disconnected robot is never available, however recent", () => {
|
|
173
|
+
assert.equal(isAvailableForWork(disconnectedState("R1", 1_000), 1_000), false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a connected, fresh, but WORKING robot is not available", () => {
|
|
177
|
+
const working = applyTrackingFrame(
|
|
178
|
+
disconnectedState("R1", 0),
|
|
179
|
+
decodeTrackingFrame(JSON.stringify(POSE)), // status 2 = cleaning
|
|
180
|
+
0
|
|
181
|
+
);
|
|
182
|
+
assert.equal(isAvailableForWork(working, 0), false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a pose update does not invent a floor", () => {
|
|
186
|
+
// The stream carries no floorNo. Anything dispatching to a floor must see
|
|
187
|
+
// floorKnown: false rather than a defaulted number.
|
|
188
|
+
const state = applyTrackingFrame(
|
|
189
|
+
disconnectedState("R1", 0),
|
|
190
|
+
decodeTrackingFrame(JSON.stringify(POSE)),
|
|
191
|
+
0
|
|
192
|
+
);
|
|
193
|
+
assert.equal(state.floorNo, null);
|
|
194
|
+
assert.equal(state.floorKnown, false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/* -------------------------------------------------------------- reconnect */
|
|
198
|
+
|
|
199
|
+
test("backoff grows exponentially and then stops at the cap", () => {
|
|
200
|
+
const always1 = () => 1; // full jitter at its maximum = the ceiling itself
|
|
201
|
+
assert.equal(trackingBackoffMs(0, always1), 1_000);
|
|
202
|
+
assert.equal(trackingBackoffMs(1, always1), 2_000);
|
|
203
|
+
assert.equal(trackingBackoffMs(2, always1), 4_000);
|
|
204
|
+
assert.equal(trackingBackoffMs(6, always1), 60_000, "capped");
|
|
205
|
+
assert.equal(trackingBackoffMs(50, always1), 60_000, "still capped");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a huge attempt count cannot produce NaN and a hot retry loop", () => {
|
|
209
|
+
// 2 ** 1e9 is Infinity, and Infinity * random() is NaN; setTimeout(NaN) fires
|
|
210
|
+
// immediately - a retry storm precisely when the cloud is least able to take
|
|
211
|
+
// it. The exponent is capped before the power is taken.
|
|
212
|
+
for (const attempt of [1e6, 1e9, Number.MAX_SAFE_INTEGER]) {
|
|
213
|
+
const delay = trackingBackoffMs(attempt);
|
|
214
|
+
assert.equal(Number.isFinite(delay), true, `attempt ${attempt}`);
|
|
215
|
+
assert.ok(delay >= 0 && delay <= 60_000, `attempt ${attempt} -> ${delay}`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a negative or fractional attempt is treated as the first", () => {
|
|
220
|
+
const always1 = () => 1;
|
|
221
|
+
assert.equal(trackingBackoffMs(-5, always1), 1_000);
|
|
222
|
+
assert.equal(trackingBackoffMs(0.9, always1), 1_000);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("jitter actually spreads reconnects apart", () => {
|
|
226
|
+
// Without this, every robot dropped by one cloud restart retries in the same
|
|
227
|
+
// millisecond, repeatedly - we become the reason it struggles to come back.
|
|
228
|
+
const delays = new Set();
|
|
229
|
+
for (let i = 0; i < 200; i++) delays.add(trackingBackoffMs(5));
|
|
230
|
+
assert.ok(delays.size > 50, `too clustered: ${delays.size} distinct values`);
|
|
231
|
+
});
|