From 35cf3d0ce5707d91850fd982fda1c0e20034bd89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:20:21 +0000 Subject: [PATCH] test: add device auth store coverage --- src/infra/device-auth-store.test.ts | 109 ++++++++++++++ src/shared/device-auth-store.test.ts | 206 +++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 src/infra/device-auth-store.test.ts create mode 100644 src/shared/device-auth-store.test.ts diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts new file mode 100644 index 00000000000..82a92492015 --- /dev/null +++ b/src/infra/device-auth-store.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { + clearDeviceAuthToken, + loadDeviceAuthToken, + storeDeviceAuthToken, +} from "./device-auth-store.js"; + +function createEnv(stateDir: string): NodeJS.ProcessEnv { + return { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }; +} + +function deviceAuthFile(stateDir: string): string { + return path.join(stateDir, "identity", "device-auth.json"); +} + +describe("infra/device-auth-store", () => { + it("stores and loads device auth tokens under the configured state dir", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + vi.spyOn(Date, "now").mockReturnValue(1234); + + const entry = storeDeviceAuthToken({ + deviceId: "device-1", + role: " operator ", + token: "secret", + scopes: [" operator.write ", "operator.read", "operator.read"], + env: createEnv(stateDir), + }); + + expect(entry).toEqual({ + token: "secret", + role: "operator", + scopes: ["operator.read", "operator.write"], + updatedAtMs: 1234, + }); + expect( + loadDeviceAuthToken({ + deviceId: "device-1", + role: "operator", + env: createEnv(stateDir), + }), + ).toEqual(entry); + + const raw = await fs.readFile(deviceAuthFile(stateDir), "utf8"); + expect(raw.endsWith("\n")).toBe(true); + expect(JSON.parse(raw)).toEqual({ + version: 1, + deviceId: "device-1", + tokens: { + operator: entry, + }, + }); + }); + }); + + it("returns null for missing, invalid, or mismatched stores", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); + + await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true }); + await fs.writeFile(deviceAuthFile(stateDir), '{"version":2,"deviceId":"device-1"}\n', "utf8"); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); + + await fs.writeFile( + deviceAuthFile(stateDir), + '{"version":1,"deviceId":"device-2","tokens":{"operator":{"token":"x","role":"operator","scopes":[],"updatedAtMs":1}}}\n', + "utf8", + ); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); + }); + }); + + it("clears only the requested role and leaves unrelated tokens intact", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + + storeDeviceAuthToken({ + deviceId: "device-1", + role: "operator", + token: "operator-token", + env, + }); + storeDeviceAuthToken({ + deviceId: "device-1", + role: "node", + token: "node-token", + env, + }); + + clearDeviceAuthToken({ + deviceId: "device-1", + role: " operator ", + env, + }); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "node", env })).toMatchObject({ + token: "node-token", + }); + }); + }); +}); diff --git a/src/shared/device-auth-store.test.ts b/src/shared/device-auth-store.test.ts new file mode 100644 index 00000000000..be070ee79cd --- /dev/null +++ b/src/shared/device-auth-store.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearDeviceAuthTokenFromStore, + loadDeviceAuthTokenFromStore, + storeDeviceAuthTokenInStore, + type DeviceAuthStoreAdapter, +} from "./device-auth-store.js"; + +function createAdapter(initialStore: ReturnType = null) { + let store = initialStore; + const writes: unknown[] = []; + const adapter: DeviceAuthStoreAdapter = { + readStore: () => store, + writeStore: (next) => { + store = next; + writes.push(next); + }, + }; + return { adapter, writes, readStore: () => store }; +} + +describe("device-auth-store", () => { + it("loads only matching device ids and normalized roles", () => { + const { adapter } = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "secret", + role: "operator", + scopes: ["operator.read"], + updatedAtMs: 1, + }, + }, + }); + + expect( + loadDeviceAuthTokenFromStore({ + adapter, + deviceId: "device-1", + role: " operator ", + }), + ).toMatchObject({ token: "secret" }); + expect( + loadDeviceAuthTokenFromStore({ + adapter, + deviceId: "device-2", + role: "operator", + }), + ).toBeNull(); + }); + + it("stores normalized roles and deduped sorted scopes while preserving same-device tokens", () => { + vi.spyOn(Date, "now").mockReturnValue(1234); + const { adapter, writes, readStore } = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: { + node: { + token: "node-token", + role: "node", + scopes: ["node.invoke"], + updatedAtMs: 10, + }, + }, + }); + + const entry = storeDeviceAuthTokenInStore({ + adapter, + deviceId: "device-1", + role: " operator ", + token: "operator-token", + scopes: [" operator.write ", "operator.read", "operator.read", ""], + }); + + expect(entry).toEqual({ + token: "operator-token", + role: "operator", + scopes: ["operator.read", "operator.write"], + updatedAtMs: 1234, + }); + expect(writes).toHaveLength(1); + expect(readStore()).toEqual({ + version: 1, + deviceId: "device-1", + tokens: { + node: { + token: "node-token", + role: "node", + scopes: ["node.invoke"], + updatedAtMs: 10, + }, + operator: entry, + }, + }); + }); + + it("replaces stale stores from other devices instead of merging them", () => { + const { adapter, readStore } = createAdapter({ + version: 1, + deviceId: "device-2", + tokens: { + operator: { + token: "old-token", + role: "operator", + scopes: [], + updatedAtMs: 1, + }, + }, + }); + + storeDeviceAuthTokenInStore({ + adapter, + deviceId: "device-1", + role: "node", + token: "node-token", + }); + + expect(readStore()).toEqual({ + version: 1, + deviceId: "device-1", + tokens: { + node: { + token: "node-token", + role: "node", + scopes: [], + updatedAtMs: expect.any(Number), + }, + }, + }); + }); + + it("avoids writes when clearing missing roles or mismatched devices", () => { + const missingRole = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: {}, + }); + clearDeviceAuthTokenFromStore({ + adapter: missingRole.adapter, + deviceId: "device-1", + role: "operator", + }); + expect(missingRole.writes).toHaveLength(0); + + const otherDevice = createAdapter({ + version: 1, + deviceId: "device-2", + tokens: { + operator: { + token: "secret", + role: "operator", + scopes: [], + updatedAtMs: 1, + }, + }, + }); + clearDeviceAuthTokenFromStore({ + adapter: otherDevice.adapter, + deviceId: "device-1", + role: "operator", + }); + expect(otherDevice.writes).toHaveLength(0); + }); + + it("removes normalized roles when clearing stored tokens", () => { + const { adapter, writes, readStore } = createAdapter({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "secret", + role: "operator", + scopes: ["operator.read"], + updatedAtMs: 1, + }, + node: { + token: "node-token", + role: "node", + scopes: [], + updatedAtMs: 2, + }, + }, + }); + + clearDeviceAuthTokenFromStore({ + adapter, + deviceId: "device-1", + role: " operator ", + }); + + expect(writes).toHaveLength(1); + expect(readStore()).toEqual({ + version: 1, + deviceId: "device-1", + tokens: { + node: { + token: "node-token", + role: "node", + scopes: [], + updatedAtMs: 2, + }, + }, + }); + }); +});