@7365admin1/core 3.80.0 → 3.82.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.
@@ -0,0 +1,222 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+
5
+ import {
6
+ ROBOT_CLOUD_PATHS,
7
+ ROBOT_STATUS_NAMES,
8
+ areaColourToHex,
9
+ hexToAreaColour,
10
+ isDispatchable,
11
+ metresToPixel,
12
+ pixelToMetres,
13
+ planDayFromJsDay,
14
+ robotCloudBody,
15
+ robotCloudSign,
16
+ robotCloudSigningString,
17
+ robotCloudTimestamp,
18
+ robotMode,
19
+ robotModeFromList,
20
+ } from "./.build/utils/robot-cloud.util.mjs";
21
+
22
+ /* --------------------------------------------------------------------------
23
+ * What these guard.
24
+ *
25
+ * The cleaning-robot cloud authenticates every request with a SHA-1 over a
26
+ * string whose field order, separators and url path are all load-bearing: get
27
+ * any character wrong and every call is rejected with no clue why. And its
28
+ * status vocabulary is 27 codes deep, where mapping one of them to the wrong
29
+ * side of "is this robot free?" sends a second job to a robot already cleaning.
30
+ *
31
+ * Source: "Robot Cloud API Document (2025.05.27)".
32
+ * ----------------------------------------------------------------------- */
33
+
34
+ /* ------------------------------------------------------------------ signing */
35
+
36
+ test("the signing string is exactly the documented field order", () => {
37
+ assert.equal(
38
+ robotCloudSigningString("APPID", "20250520200000", "/portal/x", "SECRET"),
39
+ "appid=APPID,timestamp=20250520200000,url=/portal/x,secret=SECRET"
40
+ );
41
+ });
42
+
43
+ test("the signature is sha1 of that string, in lower-case hex", () => {
44
+ const expected = createHash("sha1")
45
+ .update("appid=a,timestamp=20250520200000,url=/portal/x,secret=s")
46
+ .digest("hex");
47
+
48
+ assert.equal(robotCloudSign("a", "20250520200000", "/portal/x", "s"), expected);
49
+ assert.match(
50
+ robotCloudSign("a", "20250520200000", "/portal/x", "s"),
51
+ /^[0-9a-f]{40}$/
52
+ );
53
+ });
54
+
55
+ test("the url is part of the hash, so two endpoints never share a signature", () => {
56
+ // The document's own examples show get-map-list and get-file-url carrying the
57
+ // SAME sign at the same timestamp, which its own formula makes impossible.
58
+ // That is a copy-paste in the PDF; this asserts the formula, not the PDF.
59
+ const a = robotCloudSign("a", "20250520200000", ROBOT_CLOUD_PATHS.mapList, "s");
60
+ const b = robotCloudSign("a", "20250520200000", ROBOT_CLOUD_PATHS.fileUrl, "s");
61
+ assert.notEqual(a, b);
62
+ });
63
+
64
+ test("a path without a leading slash is refused rather than silently signed", () => {
65
+ assert.throws(
66
+ () => robotCloudSign("a", "20250520200000", "portal/x", "s"),
67
+ /must start with/
68
+ );
69
+ });
70
+
71
+ test("missing credentials throw instead of signing an empty secret", () => {
72
+ assert.throws(() => robotCloudSign("", "20250520200000", "/p", "s"), /appid/);
73
+ assert.throws(() => robotCloudSign("a", "20250520200000", "/p", ""), /secret/);
74
+ });
75
+
76
+ test("the timestamp is yyyyMMddHHmmss, zero-padded", () => {
77
+ assert.equal(
78
+ robotCloudTimestamp(new Date(Date.UTC(2025, 4, 20, 20, 0, 0))),
79
+ "20250520200000"
80
+ );
81
+ // Single-digit month, day, hour, minute and second all pad.
82
+ assert.equal(
83
+ robotCloudTimestamp(new Date(Date.UTC(2025, 0, 2, 3, 4, 5))),
84
+ "20250102030405"
85
+ );
86
+ });
87
+
88
+ test("the secret is hashed and never becomes a field in the body", () => {
89
+ const body = robotCloudBody(
90
+ "APPID",
91
+ "SUPER-SECRET",
92
+ ROBOT_CLOUD_PATHS.robotList,
93
+ { robotSn: "CASDF03456" },
94
+ new Date(Date.UTC(2025, 4, 20, 20, 0, 0))
95
+ );
96
+
97
+ assert.deepEqual(Object.keys(body).sort(), [
98
+ "appid",
99
+ "robotSn",
100
+ "sign",
101
+ "timestamp",
102
+ ]);
103
+ assert.equal(
104
+ JSON.stringify(body).includes("SUPER-SECRET"),
105
+ false,
106
+ "the secret must never be transmitted"
107
+ );
108
+ });
109
+
110
+ /* ------------------------------------------------------------------ status */
111
+
112
+ test("an unknown status is UNAVAILABLE, never dispatchable", () => {
113
+ // The branch that matters: a firmware code we have never seen must not read
114
+ // as "free, send it a job".
115
+ for (const unknown of [42, -1, 998, null, undefined, "1", NaN]) {
116
+ assert.equal(robotMode(unknown), "UNAVAILABLE", `status ${unknown}`);
117
+ assert.equal(isDispatchable(unknown), false, `status ${unknown}`);
118
+ }
119
+ });
120
+
121
+ test("only idle, ready and charged robots are dispatchable", () => {
122
+ assert.deepEqual(
123
+ Object.keys(ROBOT_STATUS_NAMES)
124
+ .map(Number)
125
+ .filter(isDispatchable)
126
+ .sort((a, b) => a - b),
127
+ [1, 9, 24]
128
+ );
129
+ });
130
+
131
+ test("every cleaning status is WORKING and refuses a second job", () => {
132
+ for (const cleaning of [2, 10, 12]) {
133
+ assert.equal(robotMode(cleaning), "WORKING");
134
+ assert.equal(isDispatchable(cleaning), false);
135
+ }
136
+ });
137
+
138
+ test("switching floor is IN_TRANSIT - the lift case, not an idle robot", () => {
139
+ assert.equal(robotMode(21), "IN_TRANSIT");
140
+ assert.equal(isDispatchable(21), false);
141
+ });
142
+
143
+ test("every documented status has both a name and a mode", () => {
144
+ for (const code of Object.keys(ROBOT_STATUS_NAMES).map(Number)) {
145
+ assert.ok(ROBOT_STATUS_NAMES[code], `status ${code} has no name`);
146
+ assert.notEqual(
147
+ robotMode(code),
148
+ undefined,
149
+ `status ${code} has no mode`
150
+ );
151
+ }
152
+ });
153
+
154
+ test("offline beats a stale cached status", () => {
155
+ // get-robot-list can report a status from before the robot dropped off.
156
+ assert.equal(robotModeFromList({ statusFlag: 1, onlineFlag: false }), "UNAVAILABLE");
157
+ assert.equal(robotModeFromList({ statusFlag: 1, onlineFlag: true }), "IDLE");
158
+ });
159
+
160
+ /* ------------------------------------------------------------- coordinates */
161
+
162
+ test("pixels and metres round-trip through the map transform", () => {
163
+ // The example map from the document.
164
+ const map = { originX: -49.713, originY: -35.846, resolution: 0.05 };
165
+ const height = 800;
166
+
167
+ const metres = pixelToMetres(504, 416, map, height);
168
+ const back = metresToPixel(metres.x, metres.y, map, height);
169
+
170
+ assert.deepEqual(back, { x: 504, y: 416 });
171
+ });
172
+
173
+ test("the transform matches the documented formula", () => {
174
+ const map = { originX: -49.713, originY: -35.846, resolution: 0.05 };
175
+ const { x, y } = pixelToMetres(100, 200, map, 800);
176
+
177
+ assert.equal(Number(x.toFixed(4)), Number((-49.713 + 100 * 0.05).toFixed(4)));
178
+ assert.equal(
179
+ Number(y.toFixed(4)),
180
+ Number((-35.846 + (800 - 200) * 0.05).toFixed(4))
181
+ );
182
+ });
183
+
184
+ test("a zero resolution throws rather than dividing by zero", () => {
185
+ assert.throws(
186
+ () => metresToPixel(0, 0, { originX: 0, originY: 0, resolution: 0 }, 800),
187
+ /resolution/
188
+ );
189
+ });
190
+
191
+ /* ------------------------------------------------------------------- areas */
192
+
193
+ test("area colours round-trip between the map's hex and the plan's object", () => {
194
+ // The two endpoints name the same area two different ways.
195
+ assert.deepEqual(hexToAreaColour("FF0B0B83"), {
196
+ r: 255,
197
+ g: 11,
198
+ b: 11,
199
+ a: 131,
200
+ });
201
+ assert.equal(areaColourToHex({ r: 255, g: 11, b: 11, a: 131 }), "FF0B0B83");
202
+ assert.equal(areaColourToHex(hexToAreaColour("B7CAEFFF")), "B7CAEFFF");
203
+ });
204
+
205
+ test("the plan example's colour survives the round trip", () => {
206
+ const fromPlan = { r: 186, g: 20, b: 75, a: 255 };
207
+ assert.deepEqual(hexToAreaColour(areaColourToHex(fromPlan)), fromPlan);
208
+ });
209
+
210
+ test("a malformed colour is refused, not half-parsed", () => {
211
+ assert.throws(() => hexToAreaColour("FF0B0B"), /8 hex digits/);
212
+ assert.throws(() => hexToAreaColour("ZZ0B0B83"), /8 hex digits/);
213
+ assert.throws(() => areaColourToHex({ r: 256, g: 0, b: 0, a: 0 }), /out of range/);
214
+ });
215
+
216
+ /* --------------------------------------------------------------- job plans */
217
+
218
+ test("plan days are 1-7 Monday-Sunday, not JavaScript's 0-6 Sunday-Saturday", () => {
219
+ assert.equal(planDayFromJsDay(0), 7, "Sunday");
220
+ assert.equal(planDayFromJsDay(1), 1, "Monday");
221
+ assert.equal(planDayFromJsDay(6), 6, "Saturday");
222
+ });
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { parseRobotCommand } from "./.build/utils/robot-cloud.util.mjs";
5
+
6
+ /* --------------------------------------------------------------------------
7
+ * POST /robots/:id/command is the only endpoint that makes a physical machine
8
+ * move, and the vendor API returns no command id - so a command sent wrongly
9
+ * cannot be identified afterwards, let alone cancelled on its own.
10
+ *
11
+ * The rule these guard: fields are REFUSED on the commands that do not use
12
+ * them, never ignored. Silently dropping a spot polygon from an area command
13
+ * would send the robot to clean the wrong thing and report success.
14
+ * ----------------------------------------------------------------------- */
15
+
16
+ const ok = (body) => {
17
+ const r = parseRobotCommand(body);
18
+ assert.equal(r.ok, true, r.ok ? "" : r.message);
19
+ return r.command;
20
+ };
21
+ const bad = (body, re) => {
22
+ const r = parseRobotCommand(body);
23
+ assert.equal(r.ok, false, `expected refusal for ${JSON.stringify(body)}`);
24
+ if (re) assert.match(r.message, re);
25
+ return r.message;
26
+ };
27
+
28
+ const DEFAULTS = { cleanTimes: 1, modeFlag: 1, subModeFlag: 0, levelFlag: 1 };
29
+
30
+ /* ------------------------------------------------------------- the kinds */
31
+
32
+ test("a full clean takes the documented defaults", () => {
33
+ assert.deepEqual(ok({ kind: "full" }), { kind: "full", options: DEFAULTS });
34
+ });
35
+
36
+ test("an area clean carries its area names", () => {
37
+ assert.deepEqual(ok({ kind: "area", areas: ["AREA-FF0B0B83"] }), {
38
+ kind: "area",
39
+ areas: ["AREA-FF0B0B83"],
40
+ options: DEFAULTS,
41
+ });
42
+ });
43
+
44
+ test("a spot clean carries its polygon", () => {
45
+ const points = [
46
+ { x: 504, y: 416 }, { x: 504, y: 480 },
47
+ { x: 555, y: 480 }, { x: 555, y: 416 },
48
+ ];
49
+ assert.deepEqual(ok({ kind: "spot", points }), {
50
+ kind: "spot", points, options: DEFAULTS,
51
+ });
52
+ });
53
+
54
+ test("a return command carries nothing at all", () => {
55
+ assert.deepEqual(ok({ kind: "return" }), { kind: "return" });
56
+ });
57
+
58
+ test("an unknown kind is refused", () => {
59
+ for (const kind of ["mop", "", null, undefined, 1, "FULL"]) {
60
+ bad({ kind }, /kind must be one of/);
61
+ }
62
+ bad(null, /command body is required/);
63
+ bad("full", /command body is required/);
64
+ });
65
+
66
+ /* ------------------------------- fields refused where they do not belong */
67
+
68
+ test("a spot polygon on an area command is REFUSED, not ignored", () => {
69
+ // Ignoring it would clean the wrong thing and report success.
70
+ bad({ kind: "area", areas: ["A"], points: [{ x: 1, y: 1 }] }, /points is only valid/);
71
+ });
72
+
73
+ test("area names on a spot or full command are refused", () => {
74
+ bad({ kind: "spot", points: [{x:1,y:1},{x:2,y:2},{x:3,y:3}], areas: ["A"] }, /areas is only valid/);
75
+ bad({ kind: "full", areas: ["A"] }, /areas is only valid/);
76
+ });
77
+
78
+ test("a return command refuses cleaning options and shapes", () => {
79
+ // Returning to dock has no mode, no level and no area.
80
+ for (const extra of [
81
+ { cleanTimes: 2 }, { modeFlag: 2 }, { subModeFlag: 1 }, { levelFlag: 3 },
82
+ { areas: ["A"] }, { points: [{ x: 1, y: 1 }] },
83
+ ]) {
84
+ bad({ kind: "return", ...extra }, /not valid for a return command/);
85
+ }
86
+ });
87
+
88
+ /* --------------------------------------------------------- option ranges */
89
+
90
+ test("modeFlag, subModeFlag and levelFlag accept only their documented values", () => {
91
+ // 1 vacuum / 2 washing / 3 dry dust mop — confirmed against the three buttons
92
+ // on the robot's handlebar.
93
+ for (const v of [1, 2, 3]) assert.equal(ok({ kind: "full", modeFlag: v }).options.modeFlag, v);
94
+ for (const v of [0, 4, -1, 1.5, "1", null]) bad({ kind: "full", modeFlag: v }, /modeFlag/);
95
+
96
+ for (const v of [0, 1]) assert.equal(ok({ kind: "full", subModeFlag: v }).options.subModeFlag, v);
97
+ for (const v of [2, -1, "0"]) bad({ kind: "full", subModeFlag: v }, /subModeFlag/);
98
+
99
+ for (const v of [1, 2, 3]) assert.equal(ok({ kind: "full", levelFlag: v }).options.levelFlag, v);
100
+ for (const v of [0, 4, "2"]) bad({ kind: "full", levelFlag: v }, /levelFlag/);
101
+ });
102
+
103
+ test("cleanTimes accepts 0 (forever) through 99, and nothing else", () => {
104
+ assert.equal(ok({ kind: "full", cleanTimes: 0 }).options.cleanTimes, 0);
105
+ assert.equal(ok({ kind: "full", cleanTimes: 99 }).options.cleanTimes, 99);
106
+ for (const v of [-1, 100, 1.5, "3", null, NaN]) bad({ kind: "full", cleanTimes: v }, /cleanTimes/);
107
+ });
108
+
109
+ /* ------------------------------------------------------------- the shapes */
110
+
111
+ test("an area command needs at least one real area name", () => {
112
+ bad({ kind: "area" }, /areas must be/);
113
+ bad({ kind: "area", areas: [] }, /areas must be/);
114
+ bad({ kind: "area", areas: [""] }, /areas must be/);
115
+ bad({ kind: "area", areas: [1, 2] }, /areas must be/);
116
+ bad({ kind: "area", areas: "AREA-1" }, /areas must be/);
117
+ });
118
+
119
+ test("a spot command needs at least 3 points — fewer cannot enclose anything", () => {
120
+ bad({ kind: "spot" }, /at least 3/);
121
+ bad({ kind: "spot", points: [{ x: 1, y: 1 }] }, /at least 3/);
122
+ bad({ kind: "spot", points: [{ x: 1, y: 1 }, { x: 2, y: 2 }] }, /at least 3/);
123
+ });
124
+
125
+ test("a spot point must be two finite numbers", () => {
126
+ const three = (p) => [{ x: 1, y: 1 }, { x: 2, y: 2 }, p];
127
+ for (const p of [
128
+ { x: 1 }, { y: 1 }, { x: "1", y: 1 }, { x: 1, y: null },
129
+ { x: 1e999, y: 1 }, null, "1,1", [1, 1],
130
+ ]) {
131
+ bad({ kind: "spot", points: three(p) }, /points must be/);
132
+ }
133
+ });
134
+
135
+ test("a prototype key cannot masquerade as a command field", () => {
136
+ // hasOwnProperty, not a bare `in` — otherwise "constructor" reads as present.
137
+ const cmd = ok({ kind: "full" });
138
+ assert.deepEqual(cmd.options, DEFAULTS);
139
+ assert.equal(parseRobotCommand({ kind: "return", constructor: 1 }).ok, true);
140
+ });
@@ -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
+ });