test: sign gateway auth payloads

This commit is contained in:
Shakker
2026-05-12 18:01:20 +01:00
parent c3c4510942
commit a390424442

View File

@@ -146,6 +146,23 @@ function requireFirstSignCall(): [privateKey: string, payload: string] {
return [privateKey, payload];
}
function expectSignedPayloadFields(
payload: string | undefined,
params: { scopes: string[]; token: string; nonce: string },
) {
expect(payload?.split("|")).toEqual([
"v2",
"device-1",
"openclaw-control-ui",
"webchat",
"operator",
params.scopes.join(","),
expect.stringMatching(/^\d+$/),
params.token,
params.nonce,
]);
}
function expectLatestRequestTiming(
onRequestTiming: ReturnType<typeof vi.fn>,
expected: Partial<RequestTimingPayload>,
@@ -314,8 +331,6 @@ describe("GatewayBrowserClient", () => {
expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.scopes).toEqual([...CONTROL_UI_OPERATOR_SCOPES]);
expect(connectFrame.params?.scopes).toContain("operator.admin");
expect(connectFrame.params?.scopes).toContain("operator.pairing");
});
it("reuses cached device token scopes when connecting from bootstrap handoff", async () => {
@@ -334,9 +349,12 @@ describe("GatewayBrowserClient", () => {
expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth?.token).toBe("bootstrap-device-token");
expect(connectFrame.params?.scopes).toEqual([
"operator.approvals",
"operator.read",
"operator.write",
]);
expect(connectFrame.params?.scopes).toEqual(storedEntry.scopes);
expect(connectFrame.params?.scopes).not.toContain("operator.admin");
expect(connectFrame.params?.scopes).not.toContain("operator.pairing");
});
it("reports browser security errors from WebSocket construction without retrying", async () => {
@@ -515,8 +533,11 @@ describe("GatewayBrowserClient", () => {
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
const [privateKey, signedPayload] = requireFirstSignCall();
expect(privateKey).toBe("private-key");
expect(signedPayload).toContain("|shared-auth-token|nonce-1");
expect(signedPayload).not.toContain("stored-device-token");
expectSignedPayloadFields(signedPayload, {
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
token: "shared-auth-token",
nonce: "nonce-1",
});
});
it("sends explicit shared token on insecure first connect without cached device fallback", async () => {
@@ -571,7 +592,17 @@ describe("GatewayBrowserClient", () => {
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
const [privateKey, signedPayload] = requireFirstSignCall();
expect(privateKey).toBe("private-key");
expect(signedPayload).toContain("|stored-device-token|nonce-1");
expectSignedPayloadFields(signedPayload, {
scopes: [
"operator.admin",
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
],
token: "stored-device-token",
nonce: "nonce-1",
});
});
it("ignores cached operator device tokens that do not include read access", async () => {
@@ -592,7 +623,11 @@ describe("GatewayBrowserClient", () => {
expect(connectFrame.method).toBe("connect");
expect(connectFrame.params?.auth?.token).toBeUndefined();
const [, signedPayload] = requireFirstSignCall();
expect(signedPayload).not.toContain("under-scoped-device-token");
expectSignedPayloadFields(signedPayload, {
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
token: "",
nonce: "nonce-1",
});
});
it("retries once with device token after token mismatch when shared token is explicit", async () => {
@@ -793,7 +828,11 @@ describe("GatewayBrowserClient", () => {
const { connectFrame } = await continueConnect(secondWs, "nonce-current");
expect(connectFrame.method).toBe("connect");
const signedPayload = signDevicePayloadMock.mock.calls.at(-1)?.[1];
expect(signedPayload).toContain("|shared-auth-token|nonce-current");
expectSignedPayloadFields(signedPayload, {
scopes: [...CONTROL_UI_OPERATOR_SCOPES],
token: "shared-auth-token",
nonce: "nonce-current",
});
client.stop();
vi.useRealTimers();