import crypto from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveOAuthDir } from "../config/paths.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { withEnvAsync } from "../test-utils/env.js"; import { addChannelAllowFromStoreEntry, clearPairingAllowFromReadCacheForTest, approveChannelPairingCode, listChannelPairingRequests, readChannelAllowFromStore, readLegacyChannelAllowFromStore, readLegacyChannelAllowFromStoreSync, readChannelAllowFromStoreSync, removeChannelAllowFromStoreEntry, 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 }); } }); beforeEach(() => { clearPairingAllowFromReadCacheForTest(); }); async function withTempStateDir(fn: (stateDir: string) => Promise) { const dir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(dir, { recursive: true }); return await withEnvAsync({ OPENCLAW_STATE_DIR: dir }, async () => await fn(dir)); } async function writeJsonFixture(filePath: string, value: unknown) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function resolvePairingFilePath(stateDir: string, channel: string) { return path.join(resolveOAuthDir(process.env, stateDir), `${channel}-pairing.json`); } function resolveAllowFromFilePath(stateDir: string, channel: string, accountId?: string) { const suffix = accountId ? `-${accountId}` : ""; return path.join(resolveOAuthDir(process.env, stateDir), `${channel}${suffix}-allowFrom.json`); } async function writeAllowFromFixture(params: { stateDir: string; channel: string; allowFrom: string[]; accountId?: string; }) { await writeJsonFixture( resolveAllowFromFilePath(params.stateDir, params.channel, params.accountId), { version: 1, allowFrom: params.allowFrom, }, ); } async function createTelegramPairingRequest(accountId: string, id = "12345") { const created = await upsertChannelPairingRequest({ channel: "telegram", accountId, id, }); expect(created.created).toBe(true); return created; } async function seedTelegramAllowFromFixtures(params: { stateDir: string; scopedAccountId: string; scopedAllowFrom: string[]; legacyAllowFrom?: string[]; }) { await writeAllowFromFixture({ stateDir: params.stateDir, channel: "telegram", allowFrom: params.legacyAllowFrom ?? ["1001"], }); await writeAllowFromFixture({ stateDir: params.stateDir, channel: "telegram", accountId: params.scopedAccountId, allowFrom: params.scopedAllowFrom, }); } async function assertAllowFromCacheInvalidation(params: { stateDir: string; readAllowFrom: () => Promise; readSpy: { mockRestore: () => void; }; }) { const first = await params.readAllowFrom(); const second = await params.readAllowFrom(); expect(first).toEqual(["1001"]); expect(second).toEqual(["1001"]); expect(params.readSpy).toHaveBeenCalledTimes(1); await writeAllowFromFixture({ stateDir: params.stateDir, channel: "telegram", accountId: "yy", allowFrom: ["10022"], }); const third = await params.readAllowFrom(); expect(third).toEqual(["10022"]); expect(params.readSpy).toHaveBeenCalledTimes(2); } async function expectAccountScopedEntryIsolated(entry: string, accountId = "yy") { const accountScoped = await readChannelAllowFromStore("telegram", process.env, accountId); const channelScoped = await readLegacyChannelAllowFromStore("telegram"); expect(accountScoped).toContain(entry); expect(channelScoped).not.toContain(entry); } async function readScopedAllowFromPair(accountId: string) { const asyncScoped = await readChannelAllowFromStore("telegram", process.env, accountId); const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, accountId); return { asyncScoped, syncScoped }; } async function withAllowFromCacheReadSpy(params: { stateDir: string; createReadSpy: () => { mockRestore: () => void; }; readAllowFrom: () => Promise; }) { await writeAllowFromFixture({ stateDir: params.stateDir, channel: "telegram", accountId: "yy", allowFrom: ["1001"], }); const readSpy = params.createReadSpy(); await assertAllowFromCacheInvalidation({ stateDir: params.stateDir, readAllowFrom: params.readAllowFrom, readSpy, }); readSpy.mockRestore(); } describe("pairing store", () => { it("reuses pending code and reports created=false", async () => { await withTempStateDir(async () => { const first = await upsertChannelPairingRequest({ channel: "discord", id: "u1", accountId: DEFAULT_ACCOUNT_ID, }); const second = await upsertChannelPairingRequest({ channel: "discord", id: "u1", accountId: DEFAULT_ACCOUNT_ID, }); 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", accountId: DEFAULT_ACCOUNT_ID, }); expect(created.created).toBe(true); const filePath = resolvePairingFilePath(stateDir, "signal"); 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 writeJsonFixture(filePath, { version: 1, requests }); const list = await listChannelPairingRequests("signal"); expect(list).toHaveLength(0); const next = await upsertChannelPairingRequest({ channel: "signal", id: "+15550001111", accountId: DEFAULT_ACCOUNT_ID, }); 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", accountId: DEFAULT_ACCOUNT_ID, }); 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", accountId: DEFAULT_ACCOUNT_ID, }); 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, accountId: DEFAULT_ACCOUNT_ID, }); expect(created.created).toBe(true); } const blocked = await upsertChannelPairingRequest({ channel: "whatsapp", id: "+15550000004", accountId: DEFAULT_ACCOUNT_ID, }); 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", }); await expectAccountScopedEntryIsolated("12345"); }); }); it("approves pairing codes into account-scoped allowFrom via pairing metadata", async () => { await withTempStateDir(async () => { const created = await createTelegramPairingRequest("yy"); const approved = await approveChannelPairingCode({ channel: "telegram", code: created.code, }); expect(approved?.id).toBe("12345"); await expectAccountScopedEntryIsolated("12345"); }); }); it("filters approvals by account id and ignores blank approval codes", async () => { await withTempStateDir(async () => { const created = await createTelegramPairingRequest("yy"); const blank = await approveChannelPairingCode({ channel: "telegram", code: " ", }); expect(blank).toBeNull(); const mismatched = await approveChannelPairingCode({ channel: "telegram", code: created.code, accountId: "zz", }); expect(mismatched).toBeNull(); const pending = await listChannelPairingRequests("telegram"); expect(pending).toHaveLength(1); expect(pending[0]?.id).toBe("12345"); }); }); it("removes account-scoped allowFrom entries idempotently", async () => { await withTempStateDir(async () => { await addChannelAllowFromStoreEntry({ channel: "telegram", accountId: "yy", entry: "12345", }); const removed = await removeChannelAllowFromStoreEntry({ channel: "telegram", accountId: "yy", entry: "12345", }); expect(removed.changed).toBe(true); expect(removed.allowFrom).toEqual([]); const removedAgain = await removeChannelAllowFromStoreEntry({ channel: "telegram", accountId: "yy", entry: "12345", }); expect(removedAgain.changed).toBe(false); expect(removedAgain.allowFrom).toEqual([]); }); }); it("reads sync allowFrom with account-scoped isolation and wildcard filtering", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, channel: "telegram", allowFrom: ["1001", "*", " 1001 ", " "], }); await writeAllowFromFixture({ stateDir, channel: "telegram", accountId: "yy", allowFrom: [" 1002 ", "1001", "1002"], }); const scoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); const channelScoped = readLegacyChannelAllowFromStoreSync("telegram"); expect(scoped).toEqual(["1002", "1001"]); expect(channelScoped).toEqual(["1001"]); }); }); it("does not read legacy channel-scoped allowFrom for non-default account ids", async () => { await withTempStateDir(async (stateDir) => { await seedTelegramAllowFromFixtures({ stateDir, scopedAccountId: "yy", scopedAllowFrom: ["1003"], legacyAllowFrom: ["1001", "*", "1002", "1001"], }); const { asyncScoped, syncScoped } = await readScopedAllowFromPair("yy"); expect(asyncScoped).toEqual(["1003"]); expect(syncScoped).toEqual(["1003"]); }); }); it("does not fall back to legacy allowFrom when scoped file exists but is empty", async () => { await withTempStateDir(async (stateDir) => { await seedTelegramAllowFromFixtures({ stateDir, scopedAccountId: "yy", scopedAllowFrom: [], }); const { asyncScoped, syncScoped } = await readScopedAllowFromPair("yy"); expect(asyncScoped).toEqual([]); expect(syncScoped).toEqual([]); }); }); it("keeps async and sync reads aligned for malformed scoped allowFrom files", async () => { await withTempStateDir(async (stateDir) => { await writeAllowFromFixture({ stateDir, channel: "telegram", allowFrom: ["1001"], }); const malformedScopedPath = resolveAllowFromFilePath(stateDir, "telegram", "yy"); await fs.mkdir(path.dirname(malformedScopedPath), { recursive: true }); await fs.writeFile(malformedScopedPath, "{ this is not json\n", "utf8"); const asyncScoped = await readChannelAllowFromStore("telegram", process.env, "yy"); const syncScoped = readChannelAllowFromStoreSync("telegram", process.env, "yy"); expect(asyncScoped).toEqual([]); expect(syncScoped).toEqual([]); }); }); it("does not reuse pairing requests across accounts for the same sender id", async () => { await withTempStateDir(async () => { const first = await upsertChannelPairingRequest({ channel: "telegram", accountId: "alpha", id: "12345", }); const second = await upsertChannelPairingRequest({ channel: "telegram", accountId: "beta", id: "12345", }); expect(first.created).toBe(true); expect(second.created).toBe(true); expect(second.code).not.toBe(first.code); const alpha = await listChannelPairingRequests("telegram", process.env, "alpha"); const beta = await listChannelPairingRequests("telegram", process.env, "beta"); expect(alpha).toHaveLength(1); expect(beta).toHaveLength(1); expect(alpha[0]?.code).toBe(first.code); expect(beta[0]?.code).toBe(second.code); }); }); it("reads legacy channel-scoped allowFrom for default account", async () => { await withTempStateDir(async (stateDir) => { await seedTelegramAllowFromFixtures({ stateDir, scopedAccountId: "default", scopedAllowFrom: ["1002"], }); const scoped = await readChannelAllowFromStore("telegram", process.env, DEFAULT_ACCOUNT_ID); expect(scoped).toEqual(["1002", "1001"]); }); }); it("uses default-account allowFrom when account id is omitted", async () => { await withTempStateDir(async (stateDir) => { await seedTelegramAllowFromFixtures({ stateDir, scopedAccountId: DEFAULT_ACCOUNT_ID, scopedAllowFrom: ["1002"], }); const asyncScoped = await readChannelAllowFromStore("telegram", process.env); const syncScoped = readChannelAllowFromStoreSync("telegram", process.env); expect(asyncScoped).toEqual(["1002", "1001"]); expect(syncScoped).toEqual(["1002", "1001"]); }); }); it("reuses cached async allowFrom reads and invalidates on file updates", async () => { await withTempStateDir(async (stateDir) => { await withAllowFromCacheReadSpy({ stateDir, createReadSpy: () => vi.spyOn(fs, "readFile"), readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"), }); }); }); it("reuses cached sync allowFrom reads and invalidates on file updates", async () => { await withTempStateDir(async (stateDir) => { await withAllowFromCacheReadSpy({ stateDir, createReadSpy: () => vi.spyOn(fsSync, "readFileSync"), readAllowFrom: async () => readChannelAllowFromStoreSync("telegram", process.env, "yy"), }); }); }); });