@syengup/friday-channel-next 0.0.45 → 0.1.1

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,515 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
5
+ import { __setMockNodePairingForTests } from "../../agent/node-pairing-bridge.js";
6
+
7
+ const { mockListDevices, mockApproveDevice } = vi.hoisted(() => ({
8
+ mockListDevices: vi.fn(),
9
+ mockApproveDevice: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
13
+ listDevicePairing: mockListDevices,
14
+ approveDevicePairing: mockApproveDevice,
15
+ }));
16
+
17
+ const { mockListNodePairing, mockApproveNodePairing } = vi.hoisted(() => ({
18
+ mockListNodePairing: vi.fn(),
19
+ mockApproveNodePairing: vi.fn(),
20
+ }));
21
+
22
+ import { handleHealth } from "./health.js";
23
+
24
+ class MockRes extends EventEmitter {
25
+ statusCode = 0;
26
+ headers: Record<string, string> = {};
27
+ body = "";
28
+ setHeader(name: string, value: string): void {
29
+ this.headers[name.toLowerCase()] = value;
30
+ }
31
+ end(body?: string): void {
32
+ if (body) this.body += body;
33
+ }
34
+ }
35
+
36
+ function mockReq(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
37
+ return { method, url, headers } as IncomingMessage;
38
+ }
39
+
40
+ const DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
41
+ const NODE_ID = "b91c9d5c416gc13d6883d510d7egcdbdf702c72668g8888622be2b6cf9eg17";
42
+ const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
43
+
44
+ describe("handleHealth", () => {
45
+ beforeEach(() => {
46
+ setMockRuntime();
47
+ vi.clearAllMocks();
48
+ mockListDevices.mockResolvedValue({ pending: [], paired: [] });
49
+ mockApproveDevice.mockResolvedValue({ status: "approved", requestId: REQUEST_ID });
50
+ __setMockNodePairingForTests({
51
+ listNodePairing: mockListNodePairing,
52
+ approveNodePairing: mockApproveNodePairing,
53
+ });
54
+ mockListNodePairing.mockResolvedValue({ pending: [], paired: [] });
55
+ mockApproveNodePairing.mockResolvedValue({ requestId: REQUEST_ID, node: { nodeId: NODE_ID } });
56
+ });
57
+
58
+ // --- Method & Auth ---
59
+
60
+ it("returns 405 on non-GET", async () => {
61
+ const req = mockReq("POST", "/friday-next/health");
62
+ const res = new MockRes() as unknown as ServerResponse;
63
+ await handleHealth(req, res);
64
+ expect((res as unknown as MockRes).statusCode).toBe(405);
65
+ });
66
+
67
+ it("returns 401 for missing auth", async () => {
68
+ const req = mockReq("GET", "/friday-next/health");
69
+ const res = new MockRes() as unknown as ServerResponse;
70
+ await handleHealth(req, res);
71
+ expect((res as unknown as MockRes).statusCode).toBe(401);
72
+ });
73
+
74
+ // --- Basic health (no IDs) ---
75
+
76
+ it("returns 200 with ok:true when no IDs provided", async () => {
77
+ const req = mockReq("GET", "/friday-next/health", { authorization: "Bearer test-token" });
78
+ const res = new MockRes() as unknown as ServerResponse;
79
+ await handleHealth(req, res);
80
+ expect((res as unknown as MockRes).statusCode).toBe(200);
81
+ const body = JSON.parse((res as unknown as MockRes).body);
82
+ expect(body.ok).toBe(true);
83
+ expect(body.deviceId).toBe("");
84
+ expect(body.nodeDeviceId).toBe("");
85
+ expect(body.repairActions).toBeUndefined();
86
+ });
87
+
88
+ // --- selfHeal parameter ---
89
+
90
+ it("selfHeal defaults to false (opt-in)", async () => {
91
+ mockListDevices.mockResolvedValueOnce({
92
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
93
+ paired: [],
94
+ });
95
+
96
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
97
+ authorization: "Bearer test-token",
98
+ });
99
+ const res = new MockRes() as unknown as ServerResponse;
100
+ await handleHealth(req, res);
101
+ expect((res as unknown as MockRes).statusCode).toBe(200);
102
+ const body = JSON.parse((res as unknown as MockRes).body);
103
+ expect(body.devicePairing.status).toBe("pending");
104
+ expect(mockApproveDevice).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("selfHeal=true enables auto-approve", async () => {
108
+ mockListDevices.mockResolvedValueOnce({
109
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
110
+ paired: [],
111
+ });
112
+
113
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
114
+ authorization: "Bearer test-token",
115
+ });
116
+ const res = new MockRes() as unknown as ServerResponse;
117
+ await handleHealth(req, res);
118
+ const body = JSON.parse((res as unknown as MockRes).body);
119
+ expect(body.devicePairing.status).toBe("ok");
120
+ expect(body.repairActions).toHaveLength(1);
121
+ expect(body.repairActions[0].component).toBe("devicePairing");
122
+ expect(body.repairActions[0].result).toBe("ok");
123
+ });
124
+
125
+ it("selfHeal=True (capital T) is case-insensitive and enables auto-heal", async () => {
126
+ mockListDevices.mockResolvedValueOnce({
127
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
128
+ paired: [],
129
+ });
130
+
131
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=True`, {
132
+ authorization: "Bearer test-token",
133
+ });
134
+ const res = new MockRes() as unknown as ServerResponse;
135
+ await handleHealth(req, res);
136
+ const body = JSON.parse((res as unknown as MockRes).body);
137
+ expect(body.devicePairing.status).toBe("ok");
138
+ });
139
+
140
+ it("selfHeal=FALSE is case-insensitive and stays disabled", async () => {
141
+ mockListDevices.mockResolvedValueOnce({
142
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
143
+ paired: [],
144
+ });
145
+
146
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=FALSE`, {
147
+ authorization: "Bearer test-token",
148
+ });
149
+ const res = new MockRes() as unknown as ServerResponse;
150
+ await handleHealth(req, res);
151
+ const body = JSON.parse((res as unknown as MockRes).body);
152
+ expect(body.devicePairing.status).toBe("pending");
153
+ expect(mockApproveDevice).not.toHaveBeenCalled();
154
+ });
155
+
156
+ // --- Device pairing: paired + healthy ---
157
+
158
+ it("returns ok when device is paired and healthy", async () => {
159
+ mockListDevices.mockResolvedValueOnce({
160
+ pending: [],
161
+ paired: [
162
+ { deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
163
+ ],
164
+ });
165
+
166
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
167
+ authorization: "Bearer test-token",
168
+ });
169
+ const res = new MockRes() as unknown as ServerResponse;
170
+ await handleHealth(req, res);
171
+ const body = JSON.parse((res as unknown as MockRes).body);
172
+ expect(body.ok).toBe(true);
173
+ expect(body.devicePairing.status).toBe("ok");
174
+ expect(body.devicePairing.devicePaired).toBe(true);
175
+ });
176
+
177
+ // --- Device pairing: degraded ---
178
+
179
+ it("returns degraded when device has no approved scopes", async () => {
180
+ mockListDevices.mockResolvedValueOnce({
181
+ pending: [],
182
+ paired: [
183
+ { deviceId: DEVICE_ID, approvedScopes: [], tokens: { t1: {} } },
184
+ ],
185
+ });
186
+
187
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
188
+ authorization: "Bearer test-token",
189
+ });
190
+ const res = new MockRes() as unknown as ServerResponse;
191
+ await handleHealth(req, res);
192
+ const body = JSON.parse((res as unknown as MockRes).body);
193
+ expect(body.ok).toBe(false);
194
+ expect(body.devicePairing.status).toBe("degraded");
195
+ expect(body.devicePairing.approvedScopesEmpty).toBe(true);
196
+ });
197
+
198
+ it("returns degraded when all tokens are revoked", async () => {
199
+ mockListDevices.mockResolvedValueOnce({
200
+ pending: [],
201
+ paired: [
202
+ {
203
+ deviceId: DEVICE_ID,
204
+ approvedScopes: ["operator.read"],
205
+ tokens: { t1: { revokedAtMs: 1000 } },
206
+ },
207
+ ],
208
+ });
209
+
210
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
211
+ authorization: "Bearer test-token",
212
+ });
213
+ const res = new MockRes() as unknown as ServerResponse;
214
+ await handleHealth(req, res);
215
+ const body = JSON.parse((res as unknown as MockRes).body);
216
+ expect(body.ok).toBe(false);
217
+ expect(body.devicePairing.status).toBe("degraded");
218
+ expect(body.devicePairing.tokensRevoked).toBe(true);
219
+ });
220
+
221
+ // --- Device pairing: pending ---
222
+
223
+ it("returns pending when device is pending and selfHeal is false", async () => {
224
+ mockListDevices.mockResolvedValueOnce({
225
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
226
+ paired: [],
227
+ });
228
+
229
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=false`, {
230
+ authorization: "Bearer test-token",
231
+ });
232
+ const res = new MockRes() as unknown as ServerResponse;
233
+ await handleHealth(req, res);
234
+ const body = JSON.parse((res as unknown as MockRes).body);
235
+ expect(body.devicePairing.status).toBe("pending");
236
+ expect(mockApproveDevice).not.toHaveBeenCalled();
237
+ });
238
+
239
+ // --- Device pairing: not_found ---
240
+
241
+ it("returns not_found when device is not in paired or pending", async () => {
242
+ mockListDevices.mockResolvedValueOnce({ pending: [], paired: [] });
243
+
244
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
245
+ authorization: "Bearer test-token",
246
+ });
247
+ const res = new MockRes() as unknown as ServerResponse;
248
+ await handleHealth(req, res);
249
+ const body = JSON.parse((res as unknown as MockRes).body);
250
+ expect(body.ok).toBe(false);
251
+ expect(body.devicePairing.status).toBe("not_found");
252
+ });
253
+
254
+ // --- Device pairing: listDevicePairing throws ---
255
+
256
+ it("returns failed when listDevicePairing throws", async () => {
257
+ mockListDevices.mockRejectedValueOnce(new Error("ENOENT"));
258
+
259
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}`, {
260
+ authorization: "Bearer test-token",
261
+ });
262
+ const res = new MockRes() as unknown as ServerResponse;
263
+ await handleHealth(req, res);
264
+ const body = JSON.parse((res as unknown as MockRes).body);
265
+ expect(body.devicePairing.status).toBe("failed");
266
+ expect(body.devicePairing.detail).toContain("ENOENT");
267
+ });
268
+
269
+ // --- Device pairing: approveDevicePairing throws during self-heal ---
270
+
271
+ it("returns degraded when auto-approve device throws", async () => {
272
+ mockListDevices.mockResolvedValueOnce({
273
+ pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
274
+ paired: [],
275
+ });
276
+ mockApproveDevice.mockRejectedValueOnce(new Error("unknown requestId"));
277
+
278
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&selfHeal=true`, {
279
+ authorization: "Bearer test-token",
280
+ });
281
+ const res = new MockRes() as unknown as ServerResponse;
282
+ await handleHealth(req, res);
283
+ const body = JSON.parse((res as unknown as MockRes).body);
284
+ expect(body.devicePairing.status).toBe("degraded");
285
+ expect(body.repairActions).toHaveLength(1);
286
+ expect(body.repairActions[0].result).toBe("failed");
287
+ });
288
+
289
+ // --- Device pairing: normalize case-insensitively ---
290
+
291
+ it("matches deviceId case-insensitively", async () => {
292
+ mockListDevices.mockResolvedValueOnce({
293
+ pending: [],
294
+ paired: [
295
+ { deviceId: DEVICE_ID.toUpperCase(), approvedScopes: ["operator.read"], tokens: { t1: {} } },
296
+ ],
297
+ });
298
+
299
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID.toLowerCase()}`, {
300
+ authorization: "Bearer test-token",
301
+ });
302
+ const res = new MockRes() as unknown as ServerResponse;
303
+ await handleHealth(req, res);
304
+ const body = JSON.parse((res as unknown as MockRes).body);
305
+ expect(body.devicePairing.status).toBe("ok");
306
+ });
307
+
308
+ // --- Node pairing: paired + healthy ---
309
+
310
+ it("returns ok when node is paired with required caps and commands", async () => {
311
+ mockListNodePairing.mockResolvedValueOnce({
312
+ pending: [],
313
+ paired: [
314
+ {
315
+ nodeId: NODE_ID,
316
+ caps: ["location", "canvas"],
317
+ commands: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
318
+ },
319
+ ],
320
+ });
321
+
322
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
323
+ authorization: "Bearer test-token",
324
+ });
325
+ const res = new MockRes() as unknown as ServerResponse;
326
+ await handleHealth(req, res);
327
+ const body = JSON.parse((res as unknown as MockRes).body);
328
+ expect(body.ok).toBe(true);
329
+ expect(body.nodePairing.status).toBe("ok");
330
+ expect(body.nodePairing.capsValid).toBe(true);
331
+ expect(body.nodePairing.commandsValid).toBe(true);
332
+ });
333
+
334
+ // --- Node pairing: degraded ---
335
+
336
+ it("returns degraded when node is missing required caps", async () => {
337
+ mockListNodePairing.mockResolvedValueOnce({
338
+ pending: [],
339
+ paired: [
340
+ { nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] },
341
+ ],
342
+ });
343
+
344
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
345
+ authorization: "Bearer test-token",
346
+ });
347
+ const res = new MockRes() as unknown as ServerResponse;
348
+ await handleHealth(req, res);
349
+ const body = JSON.parse((res as unknown as MockRes).body);
350
+ expect(body.ok).toBe(false);
351
+ expect(body.nodePairing.status).toBe("degraded");
352
+ expect(body.nodePairing.capsValid).toBe(false);
353
+ });
354
+
355
+ // --- Node pairing: pending + self-heal ---
356
+
357
+ it("auto-approves pending node when selfHeal=true", async () => {
358
+ mockListNodePairing.mockResolvedValueOnce({
359
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
360
+ paired: [],
361
+ });
362
+ mockApproveNodePairing.mockResolvedValueOnce({ requestId: REQUEST_ID, node: { nodeId: NODE_ID } });
363
+
364
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
365
+ authorization: "Bearer test-token",
366
+ });
367
+ const res = new MockRes() as unknown as ServerResponse;
368
+ await handleHealth(req, res);
369
+ const body = JSON.parse((res as unknown as MockRes).body);
370
+ expect(body.nodePairing.status).toBe("ok");
371
+ expect(body.repairActions).toHaveLength(1);
372
+ expect(body.repairActions[0].component).toBe("nodePairing");
373
+ expect(body.repairActions[0].result).toBe("ok");
374
+ });
375
+
376
+ // --- Node pairing: approveNodePairing returns null → degraded ---
377
+
378
+ it("returns degraded when approveNodePairing returns null", async () => {
379
+ mockListNodePairing.mockResolvedValueOnce({
380
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
381
+ paired: [],
382
+ });
383
+ mockApproveNodePairing.mockResolvedValueOnce(null);
384
+
385
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
386
+ authorization: "Bearer test-token",
387
+ });
388
+ const res = new MockRes() as unknown as ServerResponse;
389
+ await handleHealth(req, res);
390
+ const body = JSON.parse((res as unknown as MockRes).body);
391
+ expect(body.nodePairing.status).toBe("degraded");
392
+ expect(body.repairActions[0].result).toBe("failed");
393
+ });
394
+
395
+ // --- Node pairing: approveNodePairing returns empty object → degraded ---
396
+
397
+ it("returns degraded when approveNodePairing returns empty object", async () => {
398
+ mockListNodePairing.mockResolvedValueOnce({
399
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
400
+ paired: [],
401
+ });
402
+ mockApproveNodePairing.mockResolvedValueOnce({});
403
+
404
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
405
+ authorization: "Bearer test-token",
406
+ });
407
+ const res = new MockRes() as unknown as ServerResponse;
408
+ await handleHealth(req, res);
409
+ const body = JSON.parse((res as unknown as MockRes).body);
410
+ expect(body.nodePairing.status).toBe("degraded");
411
+ expect(body.repairActions[0].result).toBe("failed");
412
+ });
413
+
414
+ // --- Node pairing: forbidden → degraded ---
415
+
416
+ it("returns degraded when approveNodePairing returns forbidden", async () => {
417
+ mockListNodePairing.mockResolvedValueOnce({
418
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
419
+ paired: [],
420
+ });
421
+ mockApproveNodePairing.mockResolvedValueOnce({ status: "forbidden", missingScope: "operator.admin" });
422
+
423
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
424
+ authorization: "Bearer test-token",
425
+ });
426
+ const res = new MockRes() as unknown as ServerResponse;
427
+ await handleHealth(req, res);
428
+ const body = JSON.parse((res as unknown as MockRes).body);
429
+ expect(body.nodePairing.status).toBe("degraded");
430
+ expect(body.repairActions[0].result).toBe("failed");
431
+ });
432
+
433
+ // --- Node pairing: not_found ---
434
+
435
+ it("returns not_found when node is not in paired or pending", async () => {
436
+ mockListNodePairing.mockResolvedValueOnce({ pending: [], paired: [] });
437
+
438
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
439
+ authorization: "Bearer test-token",
440
+ });
441
+ const res = new MockRes() as unknown as ServerResponse;
442
+ await handleHealth(req, res);
443
+ const body = JSON.parse((res as unknown as MockRes).body);
444
+ expect(body.ok).toBe(false);
445
+ expect(body.nodePairing.status).toBe("not_found");
446
+ });
447
+
448
+ // --- Node pairing: listNodePairing throws ---
449
+
450
+ it("returns failed when listNodePairing throws", async () => {
451
+ mockListNodePairing.mockRejectedValueOnce(new Error("EPIPE"));
452
+
453
+ const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
454
+ authorization: "Bearer test-token",
455
+ });
456
+ const res = new MockRes() as unknown as ServerResponse;
457
+ await handleHealth(req, res);
458
+ const body = JSON.parse((res as unknown as MockRes).body);
459
+ expect(body.nodePairing.status).toBe("failed");
460
+ });
461
+
462
+ // --- Combined: device + node ---
463
+
464
+ it("checks both device and node and reports overall ok", async () => {
465
+ mockListDevices.mockResolvedValueOnce({
466
+ pending: [],
467
+ paired: [
468
+ { deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
469
+ ],
470
+ });
471
+ mockListNodePairing.mockResolvedValueOnce({
472
+ pending: [],
473
+ paired: [
474
+ {
475
+ nodeId: NODE_ID,
476
+ caps: ["location", "canvas"],
477
+ commands: ["location.get", "canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset"],
478
+ },
479
+ ],
480
+ });
481
+
482
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
483
+ authorization: "Bearer test-token",
484
+ });
485
+ const res = new MockRes() as unknown as ServerResponse;
486
+ await handleHealth(req, res);
487
+ const body = JSON.parse((res as unknown as MockRes).body);
488
+ expect(body.ok).toBe(true);
489
+ expect(body.devicePairing.status).toBe("ok");
490
+ expect(body.nodePairing.status).toBe("ok");
491
+ });
492
+
493
+ it("reports ok=false when device is ok but node is degraded", async () => {
494
+ mockListDevices.mockResolvedValueOnce({
495
+ pending: [],
496
+ paired: [
497
+ { deviceId: DEVICE_ID, approvedScopes: ["operator.read"], tokens: { t1: {} } },
498
+ ],
499
+ });
500
+ mockListNodePairing.mockResolvedValueOnce({
501
+ pending: [],
502
+ paired: [{ nodeId: NODE_ID, caps: [], commands: [] }],
503
+ });
504
+
505
+ const req = mockReq("GET", `/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`, {
506
+ authorization: "Bearer test-token",
507
+ });
508
+ const res = new MockRes() as unknown as ServerResponse;
509
+ await handleHealth(req, res);
510
+ const body = JSON.parse((res as unknown as MockRes).body);
511
+ expect(body.ok).toBe(false);
512
+ expect(body.devicePairing.status).toBe("ok");
513
+ expect(body.nodePairing.status).toBe("degraded");
514
+ });
515
+ });