mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 18:56:27 +00:00
271 lines
8.2 KiB
TypeScript
271 lines
8.2 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
|
import {
|
|
approveNodePairing,
|
|
getPairedNode,
|
|
listNodePairing,
|
|
removePairedNode,
|
|
requestNodePairing,
|
|
updatePairedNodeMetadata,
|
|
verifyNodeToken,
|
|
} from "./node-pairing.js";
|
|
|
|
async function setupPairedNode(baseDir: string): Promise<string> {
|
|
const request = await requestNodePairing(
|
|
{
|
|
nodeId: "node-1",
|
|
platform: "darwin",
|
|
commands: ["system.run"],
|
|
},
|
|
baseDir,
|
|
);
|
|
await approveNodePairing(
|
|
request.request.requestId,
|
|
{ callerScopes: ["operator.pairing", "operator.admin"] },
|
|
baseDir,
|
|
);
|
|
const paired = await getPairedNode("node-1", baseDir);
|
|
expect(typeof paired?.token).toBe("string");
|
|
expect(paired?.token.length).toBeGreaterThan(0);
|
|
return paired!.token;
|
|
}
|
|
|
|
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pairing-" });
|
|
|
|
async function withNodePairingDir<T>(run: (baseDir: string) => Promise<T>): Promise<T> {
|
|
return await run(await tempDirs.make("case"));
|
|
}
|
|
|
|
function requireRecord(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("Expected a non-array record");
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function findRecordByField<T extends Record<string, unknown>>(
|
|
records: T[],
|
|
field: string,
|
|
value: unknown,
|
|
): T {
|
|
const record = records.find((entry) => entry[field] === value);
|
|
if (!record) {
|
|
throw new Error(`Expected record with ${field}=${String(value)}`);
|
|
}
|
|
return record;
|
|
}
|
|
|
|
describe("node pairing tokens", () => {
|
|
beforeAll(async () => {
|
|
await tempDirs.setup();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDirs.cleanup();
|
|
});
|
|
|
|
test("reuses pending requests for metadata refreshes", async () => {
|
|
await withNodePairingDir(async (baseDir) => {
|
|
const first = await requestNodePairing(
|
|
{
|
|
nodeId: "node-1",
|
|
platform: "darwin",
|
|
},
|
|
baseDir,
|
|
);
|
|
const second = await requestNodePairing(
|
|
{
|
|
nodeId: "node-1",
|
|
platform: "darwin",
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
expect(first.created).toBe(true);
|
|
expect(second.created).toBe(false);
|
|
expect(second.request.requestId).toBe(first.request.requestId);
|
|
|
|
const commandFirst = await requestNodePairing(
|
|
{
|
|
nodeId: "node-2",
|
|
platform: "darwin",
|
|
commands: ["canvas.snapshot"],
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
const commandSecond = await requestNodePairing(
|
|
{
|
|
nodeId: "node-2",
|
|
platform: "darwin",
|
|
displayName: "Updated Node",
|
|
commands: ["canvas.snapshot"],
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
expect(commandSecond.created).toBe(false);
|
|
expect(commandSecond.superseded).toBeUndefined();
|
|
expect(commandSecond.request.requestId).toBe(commandFirst.request.requestId);
|
|
expect(commandSecond.request.displayName).toBe("Updated Node");
|
|
expect(commandSecond.request.commands).toEqual(["canvas.snapshot"]);
|
|
|
|
const reorderedFirst = await requestNodePairing(
|
|
{
|
|
nodeId: "node-3",
|
|
platform: "darwin",
|
|
caps: ["camera", "screen"],
|
|
commands: ["canvas.snapshot", "system.run"],
|
|
},
|
|
baseDir,
|
|
);
|
|
const reorderedSecond = await requestNodePairing(
|
|
{
|
|
nodeId: "node-3",
|
|
platform: "darwin",
|
|
caps: ["screen", "camera"],
|
|
commands: ["system.run", "canvas.snapshot"],
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
expect(reorderedSecond.created).toBe(false);
|
|
expect(reorderedSecond.superseded).toBeUndefined();
|
|
expect(reorderedSecond.request.requestId).toBe(reorderedFirst.request.requestId);
|
|
|
|
await requestNodePairing(
|
|
{
|
|
nodeId: "node-4",
|
|
platform: "darwin",
|
|
commands: ["canvas.present"],
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
const pairing = await listNodePairing(baseDir);
|
|
const pendingNode = findRecordByField(pairing.pending, "nodeId", "node-4");
|
|
expect(pendingNode.commands).toEqual(["canvas.present"]);
|
|
expect(pendingNode.requiredApproveScopes).toEqual(["operator.pairing", "operator.write"]);
|
|
expect(pairing.paired).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test("generates base64url node tokens and rejects mismatches", async () => {
|
|
await withNodePairingDir(async (baseDir) => {
|
|
const token = await setupPairedNode(baseDir);
|
|
|
|
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(Buffer.from(token, "base64url")).toHaveLength(32);
|
|
const verified = await verifyNodeToken("node-1", token, baseDir);
|
|
expect(verified.ok).toBe(true);
|
|
expect(verified.node?.nodeId).toBe("node-1");
|
|
await expect(verifyNodeToken("node-1", "x".repeat(token.length), baseDir)).resolves.toEqual({
|
|
ok: false,
|
|
});
|
|
|
|
const multibyteToken = "é".repeat(token.length);
|
|
expect(Buffer.from(multibyteToken).length).not.toBe(Buffer.from(token).length);
|
|
|
|
await expect(verifyNodeToken("node-1", multibyteToken, baseDir)).resolves.toEqual({
|
|
ok: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
test("removes paired nodes without disturbing pending requests", async () => {
|
|
await withNodePairingDir(async (baseDir) => {
|
|
await setupPairedNode(baseDir);
|
|
const pending = await requestNodePairing(
|
|
{
|
|
nodeId: "node-2",
|
|
platform: "darwin",
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
await expect(removePairedNode("node-1", baseDir)).resolves.toEqual({ nodeId: "node-1" });
|
|
await expect(removePairedNode("node-1", baseDir)).resolves.toBeNull();
|
|
await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull();
|
|
const pairing = await listNodePairing(baseDir);
|
|
expect(pairing.pending).toHaveLength(1);
|
|
expect(pairing.pending[0]?.requestId).toBe(pending.request.requestId);
|
|
expect(pairing.pending[0]?.nodeId).toBe("node-2");
|
|
expect(pairing.paired).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test("requires the right scopes to approve node requests", async () => {
|
|
await withNodePairingDir(async (baseDir) => {
|
|
const systemRunRequest = await requestNodePairing(
|
|
{
|
|
nodeId: "node-1",
|
|
platform: "darwin",
|
|
commands: ["system.run"],
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
await expect(
|
|
approveNodePairing(
|
|
systemRunRequest.request.requestId,
|
|
{ callerScopes: ["operator.pairing"] },
|
|
baseDir,
|
|
),
|
|
).resolves.toEqual({
|
|
status: "forbidden",
|
|
missingScope: "operator.admin",
|
|
});
|
|
await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull();
|
|
|
|
const commandlessRequest = await requestNodePairing(
|
|
{
|
|
nodeId: "node-2",
|
|
platform: "darwin",
|
|
},
|
|
baseDir,
|
|
);
|
|
|
|
await expect(
|
|
approveNodePairing(commandlessRequest.request.requestId, { callerScopes: [] }, baseDir),
|
|
).resolves.toEqual({
|
|
status: "forbidden",
|
|
missingScope: "operator.pairing",
|
|
});
|
|
const approved = await approveNodePairing(
|
|
commandlessRequest.request.requestId,
|
|
{ callerScopes: ["operator.pairing"] },
|
|
baseDir,
|
|
);
|
|
const approvedRecord = requireRecord(approved);
|
|
const approvedNode = requireRecord(approvedRecord.node);
|
|
expect(approvedRecord.requestId).toBe(commandlessRequest.request.requestId);
|
|
expect(approvedNode.nodeId).toBe("node-2");
|
|
expect(approvedNode.commands).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test("updates paired node last-seen metadata and reports missing nodes", async () => {
|
|
await withNodePairingDir(async (baseDir) => {
|
|
await setupPairedNode(baseDir);
|
|
|
|
await expect(
|
|
updatePairedNodeMetadata(
|
|
"node-1",
|
|
{
|
|
lastSeenAtMs: 1234,
|
|
lastSeenReason: "silent_push",
|
|
},
|
|
baseDir,
|
|
),
|
|
).resolves.toBe(true);
|
|
await expect(updatePairedNodeMetadata("missing", { lastSeenAtMs: 1 }, baseDir)).resolves.toBe(
|
|
false,
|
|
);
|
|
|
|
const pairedNode = await getPairedNode("node-1", baseDir);
|
|
expect(pairedNode?.lastSeenAtMs).toBe(1234);
|
|
expect(pairedNode?.lastSeenReason).toBe("silent_push");
|
|
});
|
|
});
|
|
});
|