Files
openclaw/src/infra/node-pairing.test.ts
Peter Steinberger 8604da8e16 Reapply "refactor: move runtime state to SQLite"
This reverts commit 694ca50e97.
2026-05-27 13:27:43 +01:00

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");
});
});
});