| 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<T>(fn: (stateDir: string) => Promise<T>) { |
| 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<string[]>; |
| 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<string[]>; |
| }) { |
| 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<Record<string, unknown>>; |
| }; |
| 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"), |
| }); |
| }); |
| }); |
| }); |
|
|