@syengup/friday-channel-next 0.0.38 → 0.0.40
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/dist/src/agent/node-pairing-bridge.d.ts +9 -0
- package/dist/src/agent/node-pairing-bridge.js +19 -0
- package/dist/src/http/handlers/device-approve.js +21 -53
- package/dist/src/http/handlers/nodes-approve.js +22 -52
- package/install.js +132 -257
- package/package.json +1 -1
- package/src/agent/node-pairing-bridge.ts +29 -0
- package/src/http/handlers/device-approve.test.ts +40 -67
- package/src/http/handlers/device-approve.ts +23 -68
- package/src/http/handlers/nodes-approve.test.ts +48 -87
- package/src/http/handlers/nodes-approve.ts +37 -68
- package/src/openclaw.d.ts +30 -0
- package/src/test-support/mock-device-bootstrap.ts +10 -0
|
@@ -4,9 +4,14 @@ import { PassThrough } from "node:stream";
|
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
5
|
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
vi.
|
|
9
|
-
|
|
7
|
+
const { mockList, mockApprove } = vi.hoisted(() => ({
|
|
8
|
+
mockList: vi.fn(),
|
|
9
|
+
mockApprove: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
|
|
13
|
+
listDevicePairing: mockList,
|
|
14
|
+
approveDevicePairing: mockApprove,
|
|
10
15
|
}));
|
|
11
16
|
|
|
12
17
|
import { handleDeviceApprove } from "./device-approve.js";
|
|
@@ -30,45 +35,13 @@ function mockReq(method: string, headers: Record<string, string> = {}): PassThro
|
|
|
30
35
|
return stream;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
function mockExecSuccess(stdout: string) {
|
|
34
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
35
|
-
child.stdout = new EventEmitter();
|
|
36
|
-
child.stderr = new EventEmitter();
|
|
37
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
|
|
38
|
-
cb(null, stdout, "");
|
|
39
|
-
return child;
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function mockExecError(message: string, stderr?: string) {
|
|
44
|
-
const err = new Error(message) as Error & { stderr: string };
|
|
45
|
-
err.stderr = stderr ?? "";
|
|
46
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
47
|
-
child.stdout = new EventEmitter();
|
|
48
|
-
child.stderr = new EventEmitter();
|
|
49
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
50
|
-
cb(err);
|
|
51
|
-
return child;
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
|
|
56
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
57
|
-
child.stdout = new EventEmitter();
|
|
58
|
-
child.stderr = new EventEmitter();
|
|
59
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
60
|
-
cb(err);
|
|
61
|
-
return child;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
38
|
const DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
|
|
66
39
|
const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
|
|
67
40
|
|
|
68
41
|
describe("handleDeviceApprove", () => {
|
|
69
42
|
beforeEach(() => {
|
|
70
43
|
setMockRuntime();
|
|
71
|
-
|
|
44
|
+
vi.clearAllMocks();
|
|
72
45
|
});
|
|
73
46
|
|
|
74
47
|
it("returns 405 on non-POST", async () => {
|
|
@@ -106,8 +79,8 @@ describe("handleDeviceApprove", () => {
|
|
|
106
79
|
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("deviceId");
|
|
107
80
|
});
|
|
108
81
|
|
|
109
|
-
it("returns 502 when
|
|
110
|
-
|
|
82
|
+
it("returns 502 when listDevicePairing fails", async () => {
|
|
83
|
+
mockList.mockRejectedValueOnce(new Error("ENOENT"));
|
|
111
84
|
|
|
112
85
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
113
86
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -118,23 +91,22 @@ describe("handleDeviceApprove", () => {
|
|
|
118
91
|
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list devices");
|
|
119
92
|
});
|
|
120
93
|
|
|
121
|
-
it("returns
|
|
122
|
-
|
|
94
|
+
it("returns 404 when listDevicePairing returns data without matching device", async () => {
|
|
95
|
+
mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", deviceId: "UNMATCHED" }], paired: [] });
|
|
123
96
|
|
|
124
97
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
125
98
|
const res = new MockRes() as unknown as ServerResponse;
|
|
126
99
|
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
127
100
|
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
128
101
|
await p;
|
|
129
|
-
expect((res as unknown as MockRes).statusCode).toBe(
|
|
130
|
-
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
|
|
102
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
131
103
|
});
|
|
132
104
|
|
|
133
105
|
it("returns 404 when deviceId not in pending list", async () => {
|
|
134
|
-
|
|
106
|
+
mockList.mockResolvedValueOnce({
|
|
135
107
|
pending: [{ requestId: "uuid-1", deviceId: "OTHER_DEVICE" }],
|
|
136
108
|
paired: [],
|
|
137
|
-
})
|
|
109
|
+
});
|
|
138
110
|
|
|
139
111
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
140
112
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -148,7 +120,7 @@ describe("handleDeviceApprove", () => {
|
|
|
148
120
|
});
|
|
149
121
|
|
|
150
122
|
it("returns 404 when pending array is empty", async () => {
|
|
151
|
-
|
|
123
|
+
mockList.mockResolvedValueOnce({ pending: [], paired: [] });
|
|
152
124
|
|
|
153
125
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
154
126
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -158,14 +130,12 @@ describe("handleDeviceApprove", () => {
|
|
|
158
130
|
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
159
131
|
});
|
|
160
132
|
|
|
161
|
-
it("returns 502 when
|
|
162
|
-
|
|
133
|
+
it("returns 502 when approveDevicePairing fails", async () => {
|
|
134
|
+
mockList.mockResolvedValueOnce({
|
|
163
135
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
164
136
|
paired: [],
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
approveErr.stderr = "unknown requestId";
|
|
168
|
-
mockExecErrorWithStderr(approveErr);
|
|
137
|
+
});
|
|
138
|
+
mockApprove.mockRejectedValueOnce(new Error("unknown requestId"));
|
|
169
139
|
|
|
170
140
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
171
141
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -174,35 +144,36 @@ describe("handleDeviceApprove", () => {
|
|
|
174
144
|
await p;
|
|
175
145
|
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
176
146
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
177
|
-
expect(body.error).toContain("Device approval
|
|
147
|
+
expect(body.error).toContain("Device approval failed");
|
|
178
148
|
expect(body.detail).toBe("unknown requestId");
|
|
179
149
|
});
|
|
180
150
|
|
|
181
|
-
it("returns
|
|
182
|
-
|
|
151
|
+
it("returns 404 when approveDevicePairing returns null", async () => {
|
|
152
|
+
mockList.mockResolvedValueOnce({
|
|
183
153
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
184
154
|
paired: [],
|
|
185
|
-
})
|
|
186
|
-
|
|
155
|
+
});
|
|
156
|
+
mockApprove.mockResolvedValueOnce(null);
|
|
187
157
|
|
|
188
158
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
189
159
|
const res = new MockRes() as unknown as ServerResponse;
|
|
190
160
|
const p = handleDeviceApprove(req as unknown as IncomingMessage, res);
|
|
191
161
|
req.end(JSON.stringify({ deviceId: DEVICE_ID }));
|
|
192
162
|
await p;
|
|
193
|
-
expect((res as unknown as MockRes).statusCode).toBe(
|
|
194
|
-
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("
|
|
163
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
164
|
+
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("not found");
|
|
195
165
|
});
|
|
196
166
|
|
|
197
167
|
it("succeeds with complete flow", async () => {
|
|
198
|
-
|
|
168
|
+
mockList.mockResolvedValueOnce({
|
|
199
169
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
200
170
|
paired: [],
|
|
201
|
-
})
|
|
202
|
-
|
|
171
|
+
});
|
|
172
|
+
mockApprove.mockResolvedValueOnce({
|
|
173
|
+
status: "approved",
|
|
203
174
|
requestId: REQUEST_ID,
|
|
204
175
|
device: { deviceId: DEVICE_ID, approvedAtMs: 1778571972361 },
|
|
205
|
-
})
|
|
176
|
+
});
|
|
206
177
|
|
|
207
178
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
208
179
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -216,18 +187,20 @@ describe("handleDeviceApprove", () => {
|
|
|
216
187
|
expect(body.deviceId).toBe(DEVICE_ID.toUpperCase());
|
|
217
188
|
expect(body.requestId).toBe(REQUEST_ID);
|
|
218
189
|
expect(body.approvedAtMs).toBe(1778571972361);
|
|
219
|
-
expect(
|
|
190
|
+
expect(mockList).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(mockApprove).toHaveBeenCalledTimes(1);
|
|
220
192
|
});
|
|
221
193
|
|
|
222
194
|
it("normalizes deviceId case-insensitively", async () => {
|
|
223
|
-
|
|
195
|
+
mockList.mockResolvedValueOnce({
|
|
224
196
|
pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
|
|
225
197
|
paired: [],
|
|
226
|
-
})
|
|
227
|
-
|
|
198
|
+
});
|
|
199
|
+
mockApprove.mockResolvedValueOnce({
|
|
200
|
+
status: "approved",
|
|
228
201
|
requestId: REQUEST_ID,
|
|
229
202
|
device: { deviceId: DEVICE_ID, approvedAtMs: 1 },
|
|
230
|
-
})
|
|
203
|
+
});
|
|
231
204
|
|
|
232
205
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
233
206
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -1,41 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import {
|
|
2
|
+
import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
|
|
3
3
|
import { readJsonBody } from "../middleware/body.js";
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
5
|
import { createFridayNextLogger } from "../../logging.js";
|
|
6
6
|
|
|
7
|
-
const EXEC_ENV = process.platform === "win32"
|
|
8
|
-
? process.env
|
|
9
|
-
: { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
|
|
10
|
-
|
|
11
|
-
interface PendingDevice {
|
|
12
|
-
requestId: string;
|
|
13
|
-
deviceId: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface DeviceListJson {
|
|
17
|
-
pending?: PendingDevice[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface ApproveJson {
|
|
21
|
-
requestId: string;
|
|
22
|
-
device?: { deviceId: string; approvedAtMs?: number };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
|
|
28
|
-
if (error) {
|
|
29
|
-
reject(error);
|
|
30
|
-
} else {
|
|
31
|
-
resolve({ stdout, stderr });
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
child.stdout?.on("data", () => { /* drain */ });
|
|
35
|
-
child.stderr?.on("data", () => { /* drain */ });
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
7
|
export async function handleDeviceApprove(
|
|
40
8
|
req: IncomingMessage,
|
|
41
9
|
res: ServerResponse,
|
|
@@ -75,32 +43,18 @@ export async function handleDeviceApprove(
|
|
|
75
43
|
|
|
76
44
|
const normalizedDeviceId = rawDeviceId.trim().toUpperCase();
|
|
77
45
|
|
|
78
|
-
let
|
|
46
|
+
let pairing;
|
|
79
47
|
try {
|
|
80
|
-
|
|
81
|
-
listStdout = result.stdout;
|
|
48
|
+
pairing = await listDevicePairing();
|
|
82
49
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
log.error(`devices list failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
-
res.statusCode = 502;
|
|
86
|
-
res.setHeader("Content-Type", "application/json");
|
|
87
|
-
res.end(JSON.stringify({ error: "Failed to list devices from gateway", detail: stderr || undefined }));
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
let listData: DeviceListJson;
|
|
92
|
-
try {
|
|
93
|
-
listData = JSON.parse(listStdout) as DeviceListJson;
|
|
94
|
-
} catch {
|
|
95
|
-
log.error(`devices list returned invalid JSON: ${listStdout.slice(0, 200)}`);
|
|
50
|
+
log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
51
|
res.statusCode = 502;
|
|
97
52
|
res.setHeader("Content-Type", "application/json");
|
|
98
|
-
res.end(JSON.stringify({ error: "
|
|
53
|
+
res.end(JSON.stringify({ error: "Failed to list devices from gateway" }));
|
|
99
54
|
return true;
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
const
|
|
103
|
-
const match = pending.find(
|
|
57
|
+
const match = pairing.pending.find(
|
|
104
58
|
(entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId,
|
|
105
59
|
);
|
|
106
60
|
|
|
@@ -117,30 +71,31 @@ export async function handleDeviceApprove(
|
|
|
117
71
|
const requestId = match.requestId;
|
|
118
72
|
log.info(`approving deviceId=${normalizedDeviceId} requestId=${requestId}`);
|
|
119
73
|
|
|
120
|
-
let
|
|
74
|
+
let approved;
|
|
121
75
|
try {
|
|
122
|
-
|
|
123
|
-
approveStdout = result.stdout;
|
|
76
|
+
approved = await approveDevicePairing(requestId);
|
|
124
77
|
} catch (err) {
|
|
125
|
-
|
|
126
|
-
log.error(`devices approve failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
78
|
+
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
79
|
res.statusCode = 502;
|
|
128
80
|
res.setHeader("Content-Type", "application/json");
|
|
129
81
|
res.end(JSON.stringify({
|
|
130
|
-
error: "Device approval
|
|
131
|
-
detail:
|
|
82
|
+
error: "Device approval failed",
|
|
83
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
132
84
|
}));
|
|
133
85
|
return true;
|
|
134
86
|
}
|
|
135
87
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
88
|
+
if (!approved) {
|
|
89
|
+
res.statusCode = 404;
|
|
90
|
+
res.setHeader("Content-Type", "application/json");
|
|
91
|
+
res.end(JSON.stringify({ error: "Pending device request not found" }));
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (approved.status === "forbidden") {
|
|
96
|
+
res.statusCode = 403;
|
|
142
97
|
res.setHeader("Content-Type", "application/json");
|
|
143
|
-
res.end(JSON.stringify({ error:
|
|
98
|
+
res.end(JSON.stringify({ error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}` }));
|
|
144
99
|
return true;
|
|
145
100
|
}
|
|
146
101
|
|
|
@@ -149,8 +104,8 @@ export async function handleDeviceApprove(
|
|
|
149
104
|
res.end(JSON.stringify({
|
|
150
105
|
ok: true,
|
|
151
106
|
deviceId: normalizedDeviceId,
|
|
152
|
-
requestId:
|
|
153
|
-
approvedAtMs:
|
|
107
|
+
requestId: approved.requestId,
|
|
108
|
+
approvedAtMs: (approved as any).device?.approvedAtMs,
|
|
154
109
|
}));
|
|
155
110
|
return true;
|
|
156
111
|
}
|
|
@@ -3,10 +3,11 @@ import { EventEmitter } from "node:events";
|
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
5
|
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
6
|
+
import { __setMockNodePairingForTests } from "../../agent/node-pairing-bridge.js";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
vi.
|
|
9
|
-
|
|
8
|
+
const { mockList, mockApprove } = vi.hoisted(() => ({
|
|
9
|
+
mockList: vi.fn(),
|
|
10
|
+
mockApprove: vi.fn(),
|
|
10
11
|
}));
|
|
11
12
|
|
|
12
13
|
import { handleNodesApprove } from "./nodes-approve.js";
|
|
@@ -30,45 +31,17 @@ function mockReq(method: string, headers: Record<string, string> = {}): PassThro
|
|
|
30
31
|
return stream;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function mockExecSuccess(stdout: string) {
|
|
34
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
35
|
-
child.stdout = new EventEmitter();
|
|
36
|
-
child.stderr = new EventEmitter();
|
|
37
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
|
|
38
|
-
cb(null, stdout, "");
|
|
39
|
-
return child;
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function mockExecError(message: string, stderr?: string) {
|
|
44
|
-
const err = new Error(message) as Error & { stderr: string };
|
|
45
|
-
err.stderr = stderr ?? "";
|
|
46
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
47
|
-
child.stdout = new EventEmitter();
|
|
48
|
-
child.stderr = new EventEmitter();
|
|
49
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
50
|
-
cb(err);
|
|
51
|
-
return child;
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
|
|
56
|
-
const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
|
|
57
|
-
child.stdout = new EventEmitter();
|
|
58
|
-
child.stderr = new EventEmitter();
|
|
59
|
-
mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
|
|
60
|
-
cb(err);
|
|
61
|
-
return child;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
34
|
const NODE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
|
|
66
|
-
const REQUEST_ID = "
|
|
35
|
+
const REQUEST_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
67
36
|
|
|
68
37
|
describe("handleNodesApprove", () => {
|
|
69
38
|
beforeEach(() => {
|
|
70
39
|
setMockRuntime();
|
|
71
|
-
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
__setMockNodePairingForTests({
|
|
42
|
+
listNodePairing: mockList,
|
|
43
|
+
approveNodePairing: mockApprove,
|
|
44
|
+
});
|
|
72
45
|
});
|
|
73
46
|
|
|
74
47
|
it("returns 405 on non-POST", async () => {
|
|
@@ -106,8 +79,8 @@ describe("handleNodesApprove", () => {
|
|
|
106
79
|
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("nodeId");
|
|
107
80
|
});
|
|
108
81
|
|
|
109
|
-
it("returns 502 when
|
|
110
|
-
|
|
82
|
+
it("returns 502 when listNodePairing fails", async () => {
|
|
83
|
+
mockList.mockRejectedValueOnce(new Error("ENOENT"));
|
|
111
84
|
|
|
112
85
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
113
86
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -118,23 +91,22 @@ describe("handleNodesApprove", () => {
|
|
|
118
91
|
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list nodes");
|
|
119
92
|
});
|
|
120
93
|
|
|
121
|
-
it("returns
|
|
122
|
-
|
|
94
|
+
it("returns 404 when listNodePairing returns data without matching node", async () => {
|
|
95
|
+
mockList.mockResolvedValueOnce({ pending: [{ requestId: "x", nodeId: "UNMATCHED" }], paired: [] });
|
|
123
96
|
|
|
124
97
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
125
98
|
const res = new MockRes() as unknown as ServerResponse;
|
|
126
99
|
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
127
100
|
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
128
101
|
await p;
|
|
129
|
-
expect((res as unknown as MockRes).statusCode).toBe(
|
|
130
|
-
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
|
|
102
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
131
103
|
});
|
|
132
104
|
|
|
133
105
|
it("returns 404 when nodeId not in pending or paired with caps", async () => {
|
|
134
|
-
|
|
106
|
+
mockList.mockResolvedValueOnce({
|
|
135
107
|
pending: [{ requestId: "uuid-1", nodeId: "OTHER_NODE" }],
|
|
136
|
-
paired: [{ nodeId: "
|
|
137
|
-
})
|
|
108
|
+
paired: [{ nodeId: "OTHER_NODE", approvedAtMs: 1, caps: ["canvas"], commands: [] }],
|
|
109
|
+
});
|
|
138
110
|
|
|
139
111
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
140
112
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -144,14 +116,13 @@ describe("handleNodesApprove", () => {
|
|
|
144
116
|
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
145
117
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
146
118
|
expect(body.error).toContain("No pending node found");
|
|
147
|
-
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
148
119
|
});
|
|
149
120
|
|
|
150
121
|
it("returns 404 when pending is empty and paired has empty caps/commands", async () => {
|
|
151
|
-
|
|
122
|
+
mockList.mockResolvedValueOnce({
|
|
152
123
|
pending: [],
|
|
153
124
|
paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: [], commands: [] }],
|
|
154
|
-
})
|
|
125
|
+
});
|
|
155
126
|
|
|
156
127
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
157
128
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -162,10 +133,10 @@ describe("handleNodesApprove", () => {
|
|
|
162
133
|
});
|
|
163
134
|
|
|
164
135
|
it("returns 200 with alreadyApproved when node in paired with caps", async () => {
|
|
165
|
-
|
|
136
|
+
mockList.mockResolvedValueOnce({
|
|
166
137
|
pending: [],
|
|
167
|
-
paired: [{ nodeId: NODE_ID, approvedAtMs:
|
|
168
|
-
})
|
|
138
|
+
paired: [{ nodeId: NODE_ID, approvedAtMs: 100, caps: ["canvas"], commands: ["canvas.present"] }],
|
|
139
|
+
});
|
|
169
140
|
|
|
170
141
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
171
142
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -177,21 +148,16 @@ describe("handleNodesApprove", () => {
|
|
|
177
148
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
178
149
|
expect(body.ok).toBe(true);
|
|
179
150
|
expect(body.alreadyApproved).toBe(true);
|
|
180
|
-
expect(body.
|
|
181
|
-
expect(body.
|
|
182
|
-
expect(body.caps).toEqual(["location", "canvas"]);
|
|
183
|
-
expect(body.commands).toEqual(["canvas.navigate"]);
|
|
184
|
-
expect(mockExecImpl).toHaveBeenCalledTimes(1); // no approve call
|
|
151
|
+
expect(body.caps).toEqual(["canvas"]);
|
|
152
|
+
expect(body.commands).toEqual(["canvas.present"]);
|
|
185
153
|
});
|
|
186
154
|
|
|
187
|
-
it("returns 502 when
|
|
188
|
-
|
|
155
|
+
it("returns 502 when approveNodePairing fails", async () => {
|
|
156
|
+
mockList.mockResolvedValueOnce({
|
|
189
157
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
190
158
|
paired: [],
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
approveErr.stderr = "unknown requestId";
|
|
194
|
-
mockExecErrorWithStderr(approveErr);
|
|
159
|
+
});
|
|
160
|
+
mockApprove.mockRejectedValueOnce(new Error("unknown requestId"));
|
|
195
161
|
|
|
196
162
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
197
163
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -200,35 +166,34 @@ describe("handleNodesApprove", () => {
|
|
|
200
166
|
await p;
|
|
201
167
|
expect((res as unknown as MockRes).statusCode).toBe(502);
|
|
202
168
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
203
|
-
expect(body.error).toContain("Node approval
|
|
169
|
+
expect(body.error).toContain("Node approval failed");
|
|
204
170
|
expect(body.detail).toBe("unknown requestId");
|
|
205
171
|
});
|
|
206
172
|
|
|
207
|
-
it("returns
|
|
208
|
-
|
|
173
|
+
it("returns 404 when approveNodePairing returns null", async () => {
|
|
174
|
+
mockList.mockResolvedValueOnce({
|
|
209
175
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
210
176
|
paired: [],
|
|
211
|
-
})
|
|
212
|
-
|
|
177
|
+
});
|
|
178
|
+
mockApprove.mockResolvedValueOnce(null);
|
|
213
179
|
|
|
214
180
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
215
181
|
const res = new MockRes() as unknown as ServerResponse;
|
|
216
182
|
const p = handleNodesApprove(req as unknown as IncomingMessage, res);
|
|
217
183
|
req.end(JSON.stringify({ nodeId: NODE_ID }));
|
|
218
184
|
await p;
|
|
219
|
-
expect((res as unknown as MockRes).statusCode).toBe(
|
|
220
|
-
expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from node approval");
|
|
185
|
+
expect((res as unknown as MockRes).statusCode).toBe(404);
|
|
221
186
|
});
|
|
222
187
|
|
|
223
188
|
it("succeeds with complete flow", async () => {
|
|
224
|
-
|
|
189
|
+
mockList.mockResolvedValueOnce({
|
|
225
190
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
226
191
|
paired: [],
|
|
227
|
-
})
|
|
228
|
-
|
|
192
|
+
});
|
|
193
|
+
mockApprove.mockResolvedValueOnce({
|
|
229
194
|
requestId: REQUEST_ID,
|
|
230
195
|
node: { nodeId: NODE_ID, approvedAtMs: 1778571972361 },
|
|
231
|
-
})
|
|
196
|
+
});
|
|
232
197
|
|
|
233
198
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
234
199
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -239,22 +204,19 @@ describe("handleNodesApprove", () => {
|
|
|
239
204
|
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
240
205
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
241
206
|
expect(body.ok).toBe(true);
|
|
242
|
-
expect(body.alreadyApproved).toBeUndefined();
|
|
243
207
|
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
244
208
|
expect(body.requestId).toBe(REQUEST_ID);
|
|
245
|
-
expect(body.approvedAtMs).toBe(1778571972361);
|
|
246
|
-
expect(mockExecImpl).toHaveBeenCalledTimes(2);
|
|
247
209
|
});
|
|
248
210
|
|
|
249
211
|
it("normalizes nodeId case-insensitively in pending", async () => {
|
|
250
|
-
|
|
251
|
-
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
212
|
+
mockList.mockResolvedValueOnce({
|
|
213
|
+
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID.toUpperCase() }],
|
|
252
214
|
paired: [],
|
|
253
|
-
})
|
|
254
|
-
|
|
215
|
+
});
|
|
216
|
+
mockApprove.mockResolvedValueOnce({
|
|
255
217
|
requestId: REQUEST_ID,
|
|
256
|
-
node: { nodeId: NODE_ID, approvedAtMs: 1 },
|
|
257
|
-
})
|
|
218
|
+
node: { nodeId: NODE_ID.toUpperCase(), approvedAtMs: 1 },
|
|
219
|
+
});
|
|
258
220
|
|
|
259
221
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
260
222
|
const res = new MockRes() as unknown as ServerResponse;
|
|
@@ -265,14 +227,13 @@ describe("handleNodesApprove", () => {
|
|
|
265
227
|
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
266
228
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
267
229
|
expect(body.ok).toBe(true);
|
|
268
|
-
expect(body.nodeId).toBe(NODE_ID.toUpperCase());
|
|
269
230
|
});
|
|
270
231
|
|
|
271
232
|
it("normalizes nodeId case-insensitively in paired", async () => {
|
|
272
|
-
|
|
233
|
+
mockList.mockResolvedValueOnce({
|
|
273
234
|
pending: [],
|
|
274
|
-
paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: ["canvas"], commands: [] }],
|
|
275
|
-
})
|
|
235
|
+
paired: [{ nodeId: NODE_ID.toUpperCase(), approvedAtMs: 1, caps: ["canvas"], commands: [] }],
|
|
236
|
+
});
|
|
276
237
|
|
|
277
238
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
278
239
|
const res = new MockRes() as unknown as ServerResponse;
|