mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
test: share gateway authz and watchdog fixtures
This commit is contained in:
@@ -21,6 +21,43 @@ async function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
function createOpenGatewayClient(requestTimeoutMs: number): {
|
||||
client: GatewayClient;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs,
|
||||
});
|
||||
const send = vi.fn();
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send,
|
||||
close: vi.fn(),
|
||||
};
|
||||
return { client, send };
|
||||
}
|
||||
|
||||
function getPendingCount(client: GatewayClient): number {
|
||||
return (client as unknown as { pending: Map<string, unknown> }).pending.size;
|
||||
}
|
||||
|
||||
function trackSettlement(promise: Promise<unknown>): () => boolean {
|
||||
let settled = false;
|
||||
void promise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
return () => settled;
|
||||
}
|
||||
|
||||
describe("GatewayClient", () => {
|
||||
let wss: WebSocketServer | null = null;
|
||||
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
|
||||
@@ -111,31 +148,19 @@ describe("GatewayClient", () => {
|
||||
test("times out unresolved requests and clears pending state", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
const send = vi.fn();
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send,
|
||||
close: vi.fn(),
|
||||
};
|
||||
const { client, send } = createOpenGatewayClient(25);
|
||||
|
||||
const requestPromise = client.request("status");
|
||||
const requestExpectation = expect(requestPromise).rejects.toThrow(
|
||||
"gateway request timeout for status",
|
||||
);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
|
||||
expect(getPendingCount(client)).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await requestExpectation;
|
||||
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
|
||||
expect(getPendingCount(client)).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -144,36 +169,16 @@ describe("GatewayClient", () => {
|
||||
test("does not auto-timeout expectFinal requests", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
const send = vi.fn();
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send,
|
||||
close: vi.fn(),
|
||||
};
|
||||
const { client, send } = createOpenGatewayClient(25);
|
||||
|
||||
let settled = false;
|
||||
const requestPromise = client.request("chat.send", undefined, { expectFinal: true });
|
||||
void requestPromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
const isSettled = trackSettlement(requestPromise);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
|
||||
expect(isSettled()).toBe(false);
|
||||
expect(getPendingCount(client)).toBe(1);
|
||||
|
||||
client.stop();
|
||||
await expect(requestPromise).rejects.toThrow("gateway client stopped");
|
||||
@@ -185,35 +190,15 @@ describe("GatewayClient", () => {
|
||||
test("clamps oversized explicit request timeouts before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
const send = vi.fn();
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send,
|
||||
close: vi.fn(),
|
||||
};
|
||||
const { client } = createOpenGatewayClient(25);
|
||||
|
||||
let settled = false;
|
||||
const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 });
|
||||
void requestPromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
const isSettled = trackSettlement(requestPromise);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
|
||||
expect(isSettled()).toBe(false);
|
||||
expect(getPendingCount(client)).toBe(1);
|
||||
|
||||
client.stop();
|
||||
await expect(requestPromise).rejects.toThrow("gateway client stopped");
|
||||
@@ -225,35 +210,15 @@ describe("GatewayClient", () => {
|
||||
test("clamps oversized default request timeouts before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 2_592_010_000,
|
||||
});
|
||||
const send = vi.fn();
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send,
|
||||
close: vi.fn(),
|
||||
};
|
||||
const { client } = createOpenGatewayClient(2_592_010_000);
|
||||
|
||||
let settled = false;
|
||||
const requestPromise = client.request("status");
|
||||
void requestPromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
const isSettled = trackSettlement(requestPromise);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
|
||||
expect(isSettled()).toBe(false);
|
||||
expect(getPendingCount(client)).toBe(1);
|
||||
|
||||
client.stop();
|
||||
await expect(requestPromise).rejects.toThrow("gateway client stopped");
|
||||
|
||||
121
src/gateway/device-authz.test-helpers.ts
Normal file
121
src/gateway/device-authz.test-helpers.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
type DeviceIdentity,
|
||||
} from "../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
rotateDeviceToken,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { trackConnectChallengeNonce } from "./test-helpers.js";
|
||||
|
||||
export function resolveDeviceIdentityPath(name: string): string {
|
||||
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
|
||||
return path.join(root, "test-device-identities", `${name}.json`);
|
||||
}
|
||||
|
||||
export function loadDeviceIdentity(name: string): {
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
publicKey: string;
|
||||
} {
|
||||
const identityPath = resolveDeviceIdentityPath(name);
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
return {
|
||||
identityPath,
|
||||
identity,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
}
|
||||
|
||||
export async function pairDeviceIdentity(params: {
|
||||
name: string;
|
||||
role: "node" | "operator";
|
||||
scopes: string[];
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
}): Promise<{
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
publicKey: string;
|
||||
}> {
|
||||
const loaded = loadDeviceIdentity(params.name);
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
});
|
||||
await approveDevicePairing(request.request.requestId);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function issueOperatorToken(params: {
|
||||
name: string;
|
||||
approvedScopes: string[];
|
||||
tokenScopes?: string[];
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
}): Promise<{
|
||||
deviceId: string;
|
||||
identityPath: string;
|
||||
token: string;
|
||||
}> {
|
||||
const paired = await pairDeviceIdentity({
|
||||
name: params.name,
|
||||
role: "operator",
|
||||
scopes: params.approvedScopes,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
});
|
||||
if (params.tokenScopes) {
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: paired.identity.deviceId,
|
||||
role: "operator",
|
||||
scopes: params.tokenScopes,
|
||||
});
|
||||
expect(rotated.ok).toBe(true);
|
||||
const token = rotated.ok ? rotated.entry.token : "";
|
||||
expect(token).toBeTruthy();
|
||||
return {
|
||||
deviceId: paired.identity.deviceId,
|
||||
identityPath: paired.identityPath,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
const device = await getPairedDevice(paired.identity.deviceId);
|
||||
const token = device?.tokens?.operator?.token ?? "";
|
||||
expect(token).toBeTruthy();
|
||||
expect(device?.approvedScopes).toEqual(params.approvedScopes);
|
||||
return {
|
||||
deviceId: paired.identity.deviceId,
|
||||
identityPath: paired.identityPath,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openTrackedWs(port: number): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
@@ -83,6 +83,15 @@ let runtimeModule: PluginRuntimeModule;
|
||||
let gatewayRequestScopeModule: GatewayRequestScopeModule;
|
||||
let methodScopesModule: MethodScopesModule;
|
||||
|
||||
function createTestLog() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestContext(label: string): GatewayRequestContext {
|
||||
return { label } as unknown as GatewayRequestContext;
|
||||
}
|
||||
@@ -115,12 +124,7 @@ async function createSubagentRuntime(
|
||||
_serverPlugins: ServerPluginsModule,
|
||||
cfg: Record<string, unknown> = {},
|
||||
): Promise<PluginRuntime["subagent"]> {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const log = createTestLog();
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
serverPluginBootstrapModule.loadGatewayStartupPlugins({
|
||||
cfg,
|
||||
@@ -144,6 +148,36 @@ async function reloadServerPluginsModule(): Promise<ServerPluginsModule> {
|
||||
return serverPluginsModule;
|
||||
}
|
||||
|
||||
function loadGatewayPluginsForTest(
|
||||
overrides: Partial<Parameters<ServerPluginsModule["loadGatewayPlugins"]>[0]> = {},
|
||||
) {
|
||||
const log = createTestLog();
|
||||
serverPluginsModule.loadGatewayPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
...overrides,
|
||||
});
|
||||
return log;
|
||||
}
|
||||
|
||||
function loadGatewayStartupPluginsForTest(
|
||||
overrides: Partial<Parameters<ServerPluginBootstrapModule["loadGatewayStartupPlugins"]>[0]> = {},
|
||||
) {
|
||||
const log = createTestLog();
|
||||
serverPluginBootstrapModule.loadGatewayStartupPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
...overrides,
|
||||
});
|
||||
return log;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadTestModules();
|
||||
});
|
||||
@@ -180,7 +214,6 @@ afterEach(() => {
|
||||
|
||||
describe("loadGatewayPlugins", () => {
|
||||
test("logs plugin errors with details", async () => {
|
||||
const { loadGatewayStartupPlugins } = serverPluginBootstrapModule;
|
||||
const diagnostics: PluginDiagnostic[] = [
|
||||
{
|
||||
level: "error",
|
||||
@@ -190,21 +223,7 @@ describe("loadGatewayPlugins", () => {
|
||||
},
|
||||
];
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
loadGatewayStartupPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
});
|
||||
const log = loadGatewayStartupPluginsForTest();
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
"[plugins] failed to load plugin: boom (plugin=telegram, source=/tmp/telegram/index.ts)",
|
||||
@@ -213,23 +232,8 @@ describe("loadGatewayPlugins", () => {
|
||||
});
|
||||
|
||||
test("loads only gateway startup plugin ids", async () => {
|
||||
const { loadGatewayPlugins } = serverPluginsModule;
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
loadGatewayPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
});
|
||||
loadGatewayPluginsForTest();
|
||||
|
||||
expect(resolveGatewayStartupPluginIds).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
@@ -244,23 +248,8 @@ describe("loadGatewayPlugins", () => {
|
||||
});
|
||||
|
||||
test("provides subagent runtime with sessions.get method aliases", async () => {
|
||||
const { loadGatewayPlugins } = serverPluginsModule;
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
loadGatewayPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
});
|
||||
loadGatewayPluginsForTest();
|
||||
|
||||
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
|
||||
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
|
||||
@@ -495,22 +484,8 @@ describe("loadGatewayPlugins", () => {
|
||||
});
|
||||
|
||||
test("can prefer setup-runtime channel plugins during startup loads", async () => {
|
||||
const { loadGatewayPlugins } = serverPluginsModule;
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
loadGatewayPlugins({
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
loadGatewayPluginsForTest({
|
||||
preferSetupRuntimeForChannelPlugins: true,
|
||||
});
|
||||
|
||||
@@ -522,24 +497,9 @@ describe("loadGatewayPlugins", () => {
|
||||
});
|
||||
|
||||
test("primes configured bindings during gateway startup", async () => {
|
||||
const { loadGatewayStartupPlugins } = serverPluginBootstrapModule;
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const cfg = {};
|
||||
loadGatewayStartupPlugins({
|
||||
cfg,
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
});
|
||||
loadGatewayStartupPluginsForTest({ cfg });
|
||||
|
||||
expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg });
|
||||
});
|
||||
@@ -555,13 +515,7 @@ describe("loadGatewayPlugins", () => {
|
||||
},
|
||||
];
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const log = createTestLog();
|
||||
|
||||
reloadDeferredGatewayPlugins({
|
||||
cfg: {},
|
||||
@@ -590,10 +544,7 @@ describe("loadGatewayPlugins", () => {
|
||||
cfg: {},
|
||||
workspaceDir: "/tmp",
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
...createTestLog(),
|
||||
},
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
|
||||
@@ -1,97 +1,31 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
type DeviceIdentity,
|
||||
} from "../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
rotateDeviceToken,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
} from "./device-authz.test-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
trackConnectChallengeNonce,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
function resolveDeviceIdentityPath(name: string): string {
|
||||
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
|
||||
return path.join(root, "test-device-identities", `${name}.json`);
|
||||
}
|
||||
|
||||
function loadDeviceIdentity(name: string): {
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
publicKey: string;
|
||||
} {
|
||||
const identityPath = resolveDeviceIdentityPath(name);
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
return {
|
||||
identityPath,
|
||||
identity,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
}
|
||||
|
||||
async function issuePairingScopedOperator(name: string): Promise<{
|
||||
identityPath: string;
|
||||
deviceId: string;
|
||||
token: string;
|
||||
}> {
|
||||
const loaded = loadDeviceIdentity(name);
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
await approveDevicePairing(request.request.requestId);
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotated.ok ? rotated.entry.token : "").toBeTruthy();
|
||||
return {
|
||||
identityPath: loaded.identityPath,
|
||||
deviceId: loaded.identity.deviceId,
|
||||
token: rotated.ok ? rotated.entry.token : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function openTrackedWs(port: number): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
describe("gateway device.pair.approve caller scope guard", () => {
|
||||
test("rejects approving device scopes above the caller session scopes", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const approver = await issuePairingScopedOperator("approve-attacker");
|
||||
const approver = await issueOperatorToken({
|
||||
name: "approve-attacker",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
const pending = loadDeviceIdentity("approve-target");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
|
||||
@@ -1,119 +1,22 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
type DeviceIdentity,
|
||||
} from "../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
rotateDeviceToken,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { getPairedDevice } from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
openTrackedWs,
|
||||
pairDeviceIdentity,
|
||||
} from "./device-authz.test-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
trackConnectChallengeNonce,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
function resolveDeviceIdentityPath(name: string): string {
|
||||
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
|
||||
return path.join(root, "test-device-identities", `${name}.json`);
|
||||
}
|
||||
|
||||
function loadDeviceIdentity(name: string): {
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
publicKey: string;
|
||||
} {
|
||||
const identityPath = resolveDeviceIdentityPath(name);
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
return {
|
||||
identityPath,
|
||||
identity,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
}
|
||||
|
||||
async function pairDevice(params: {
|
||||
name: string;
|
||||
role: "node" | "operator";
|
||||
scopes: string[];
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
}): Promise<{
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
}> {
|
||||
const loaded = loadDeviceIdentity(params.name);
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
});
|
||||
await approveDevicePairing(request.request.requestId);
|
||||
return {
|
||||
identityPath: loaded.identityPath,
|
||||
identity: loaded.identity,
|
||||
};
|
||||
}
|
||||
|
||||
async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Promise<{
|
||||
deviceId: string;
|
||||
identityPath: string;
|
||||
pairingToken: string;
|
||||
}> {
|
||||
const paired = await pairDevice({
|
||||
name,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
const rotated = await rotateDeviceToken({
|
||||
deviceId: paired.identity.deviceId,
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotated.ok).toBe(true);
|
||||
const pairingToken = rotated.ok ? rotated.entry.token : "";
|
||||
expect(pairingToken).toBeTruthy();
|
||||
return {
|
||||
deviceId: paired.identity.deviceId,
|
||||
identityPath: paired.identityPath,
|
||||
pairingToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function openTrackedWs(port: number): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function connectPairingScopedOperator(params: {
|
||||
port: number;
|
||||
identityPath: string;
|
||||
@@ -134,7 +37,7 @@ async function connectApprovedNode(params: {
|
||||
name: string;
|
||||
onInvoke: (payload: unknown) => void;
|
||||
}): Promise<GatewayClient> {
|
||||
const paired = await pairDevice({
|
||||
const paired = await pairDeviceIdentity({
|
||||
name: params.name,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
@@ -207,14 +110,20 @@ async function waitForMacrotasks(): Promise<void> {
|
||||
describe("gateway device.token.rotate caller scope guard", () => {
|
||||
test("rejects rotating an admin-approved device token above the caller session scopes", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-attacker");
|
||||
const attacker = await issueOperatorToken({
|
||||
name: "rotate-attacker",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: attacker.identityPath,
|
||||
deviceToken: attacker.pairingToken,
|
||||
deviceToken: attacker.token,
|
||||
});
|
||||
|
||||
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
|
||||
@@ -238,7 +147,13 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
|
||||
test("blocks the pairing-token to admin-node-invoke escalation chain", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-rce-attacker");
|
||||
const attacker = await issueOperatorToken({
|
||||
name: "rotate-rce-attacker",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let sawInvoke = false;
|
||||
let pairingWs: WebSocket | undefined;
|
||||
@@ -258,7 +173,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: attacker.identityPath,
|
||||
deviceToken: attacker.pairingToken,
|
||||
deviceToken: attacker.token,
|
||||
});
|
||||
|
||||
const rotate = await rpcReq<{ token?: string }>(pairingWs, "device.token.rotate", {
|
||||
@@ -274,7 +189,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
|
||||
const paired = await getPairedDevice(attacker.deviceId);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
|
||||
expect(paired?.tokens?.operator?.token).toBe(attacker.pairingToken);
|
||||
expect(paired?.tokens?.operator?.token).toBe(attacker.token);
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
nodeClient?.stop();
|
||||
@@ -286,14 +201,20 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
|
||||
test("returns the same public deny for unknown devices and caller scope failures", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-deny-shape");
|
||||
const attacker = await issueOperatorToken({
|
||||
name: "rotate-deny-shape",
|
||||
approvedScopes: ["operator.admin"],
|
||||
tokenScopes: ["operator.pairing"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: attacker.identityPath,
|
||||
deviceToken: attacker.pairingToken,
|
||||
deviceToken: attacker.token,
|
||||
});
|
||||
|
||||
const missingScope = await rpcReq(pairingWs, "device.token.rotate", {
|
||||
|
||||
@@ -1,98 +1,32 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
type DeviceIdentity,
|
||||
} from "../infra/device-identity.js";
|
||||
import * as devicePairingModule from "../infra/device-pairing.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { getPairedDevice } from "../infra/device-pairing.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
issueOperatorToken,
|
||||
loadDeviceIdentity,
|
||||
openTrackedWs,
|
||||
} from "./device-authz.test-helpers.js";
|
||||
import {
|
||||
connectOk,
|
||||
connectReq,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
startServerWithClient,
|
||||
trackConnectChallengeNonce,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
function resolveDeviceIdentityPath(name: string): string {
|
||||
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
|
||||
return path.join(root, "test-device-identities", `${name}.json`);
|
||||
}
|
||||
|
||||
function loadDeviceIdentity(name: string): {
|
||||
identityPath: string;
|
||||
identity: DeviceIdentity;
|
||||
publicKey: string;
|
||||
} {
|
||||
const identityPath = resolveDeviceIdentityPath(name);
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
return {
|
||||
identityPath,
|
||||
identity,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
}
|
||||
|
||||
async function pairReadScopedOperator(name: string): Promise<{
|
||||
deviceId: string;
|
||||
identityPath: string;
|
||||
deviceToken: string;
|
||||
}> {
|
||||
const loaded = loadDeviceIdentity(name);
|
||||
const request = await requestDevicePairing({
|
||||
deviceId: loaded.identity.deviceId,
|
||||
publicKey: loaded.publicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
await approveDevicePairing(request.request.requestId);
|
||||
|
||||
const paired = await getPairedDevice(loaded.identity.deviceId);
|
||||
const deviceToken = paired?.tokens?.operator?.token ?? "";
|
||||
expect(deviceToken).toBeTruthy();
|
||||
expect(paired?.approvedScopes).toEqual(["operator.read"]);
|
||||
|
||||
return {
|
||||
deviceId: loaded.identity.deviceId,
|
||||
identityPath: loaded.identityPath,
|
||||
deviceToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function openTrackedWs(port: number): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
describe("gateway silent scope-upgrade reconnect", () => {
|
||||
test("does not silently widen a read-scoped paired device to admin on shared-auth reconnect", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const paired = await pairReadScopedOperator("silent-scope-upgrade-reconnect-poc");
|
||||
const paired = await issueOperatorToken({
|
||||
name: "silent-scope-upgrade-reconnect-poc",
|
||||
approvedScopes: ["operator.read"],
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
});
|
||||
|
||||
let watcherWs: WebSocket | undefined;
|
||||
let sharedAuthReconnectWs: WebSocket | undefined;
|
||||
@@ -130,12 +64,12 @@ describe("gateway silent scope-upgrade reconnect", () => {
|
||||
const afterUpgradeAttempt = await getPairedDevice(paired.deviceId);
|
||||
expect(afterUpgradeAttempt?.approvedScopes).toEqual(["operator.read"]);
|
||||
expect(afterUpgradeAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.deviceToken);
|
||||
expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.token);
|
||||
|
||||
postAttemptDeviceTokenWs = await openTrackedWs(started.port);
|
||||
const afterUpgrade = await connectReq(postAttemptDeviceTokenWs, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: paired.deviceToken,
|
||||
deviceToken: paired.token,
|
||||
deviceIdentityPath: paired.identityPath,
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user