import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveOAuthDir } from "../config/paths.js"; import { captureEnv } from "../test-utils/env.js"; import { addChannelAllowFromStoreEntry, approveChannelPairingCode, listChannelPairingRequests, readChannelAllowFromStore, upsertChannelPairingRequest, } from "./pairing-store.js"; let fixtureRoot = ""; let caseId = 0; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pairing-")); }); afterAll(async () => { if (fixtureRoot) { await fs.rm(fixtureRoot, { recursive: true, force: true }); } }); async function withTempStateDir(fn: (stateDir: string) => Promise) { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); const dir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(dir, { recursive: true }); process.env.OPENCLAW_STATE_DIR = dir; try { return await fn(dir); } finally { envSnapshot.restore(); } } describe("pairing store", () => { it("reuses pending code and reports created=false", async () => { await withTempStateDir(async () => { const first = await upsertChannelPairingRequest({ channel: "discord", id: "u1", }); const second = await upsertChannelPairingRequest({ channel: "discord", id: "u1", }); expect(first.created).toBe(true); expect(second.created).toBe(false); expect(second.code).toBe(first.code); const list = await listChannelPairingRequests("discord"); expect(list).toHaveLength(1); expect(list[0]?.code).toBe(first.code); }); }); it("expires pending requests after TTL", async () => { await withTempStateDir(async (stateDir) => { const created = await upsertChannelPairingRequest({ channel: "signal", id: "+15550001111", }); expect(created.created).toBe(true); const oauthDir = resolveOAuthDir(process.env, stateDir); const filePath = path.join(oauthDir, "signal-pairing.json"); const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as { requests?: Array>; }; const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const requests = (parsed.requests ?? []).map((entry) => ({ ...entry, createdAt: expiredAt, lastSeenAt: expiredAt, })); await fs.writeFile( filePath, `${JSON.stringify({ version: 1, requests }, null, 2)}\n`, "utf8", ); const list = await listChannelPairingRequests("signal"); expect(list).toHaveLength(0); const next = await upsertChannelPairingRequest({ channel: "signal", id: "+15550001111", }); expect(next.created).toBe(true); }); }); it("regenerates when a generated code collides", async () => { await withTempStateDir(async () => { const spy = vi.spyOn(crypto, "randomInt") as unknown as { mockReturnValue: (value: number) => void; mockImplementation: (fn: () => number) => void; mockRestore: () => void; }; try { spy.mockReturnValue(0); const first = await upsertChannelPairingRequest({ channel: "telegram", id: "123", }); expect(first.code).toBe("AAAAAAAA"); const sequence = Array(8).fill(0).concat(Array(8).fill(1)); let idx = 0; spy.mockImplementation(() => sequence[idx++] ?? 1); const second = await upsertChannelPairingRequest({ channel: "telegram", id: "456", }); expect(second.code).toBe("BBBBBBBB"); } finally { spy.mockRestore(); } }); }); it("caps pending requests at the default limit", async () => { await withTempStateDir(async () => { const ids = ["+15550000001", "+15550000002", "+15550000003"]; for (const id of ids) { const created = await upsertChannelPairingRequest({ channel: "whatsapp", id, }); expect(created.created).toBe(true); } const blocked = await upsertChannelPairingRequest({ channel: "whatsapp", id: "+15550000004", }); expect(blocked.created).toBe(false); const list = await listChannelPairingRequests("whatsapp"); const listIds = list.map((entry) => entry.id); expect(listIds).toHaveLength(3); expect(listIds).toContain("+15550000001"); expect(listIds).toContain("+15550000002"); expect(listIds).toContain("+15550000003"); expect(listIds).not.toContain("+15550000004"); }); }); it("stores allowFrom entries per account when accountId is provided", async () => { await withTempStateDir(async () => { await addChannelAllowFromStoreEntry({ channel: "telegram", accountId: "yy", entry: "12345", }); const accountScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); const channelScoped = await readChannelAllowFromStore("telegram"); expect(accountScoped).toContain("12345"); expect(channelScoped).not.toContain("12345"); }); }); it("approves pairing codes into account-scoped allowFrom via pairing metadata", async () => { await withTempStateDir(async () => { const created = await upsertChannelPairingRequest({ channel: "telegram", accountId: "yy", id: "12345", }); expect(created.created).toBe(true); const approved = await approveChannelPairingCode({ channel: "telegram", code: created.code, }); expect(approved?.id).toBe("12345"); const accountScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); const channelScoped = await readChannelAllowFromStore("telegram"); expect(accountScoped).toContain("12345"); expect(channelScoped).not.toContain("12345"); }); }); it("reads legacy channel-scoped allowFrom for default account", async () => { await withTempStateDir(async (stateDir) => { const oauthDir = resolveOAuthDir(process.env, stateDir); await fs.mkdir(oauthDir, { recursive: true }); await fs.writeFile( path.join(oauthDir, "telegram-allowFrom.json"), JSON.stringify( { version: 1, allowFrom: ["1001"], }, null, 2, ) + "\n", "utf8", ); await fs.writeFile( path.join(oauthDir, "telegram-default-allowFrom.json"), JSON.stringify( { version: 1, allowFrom: ["1002"], }, null, 2, ) + "\n", "utf8", ); const scoped = await readChannelAllowFromStore("telegram", process.env, "default"); expect(scoped).toEqual(["1002", "1001"]); }); }); });