Files
openclaw/src/pairing/pairing-store.test.ts
2026-02-17 15:50:07 +09:00

226 lines
6.9 KiB
TypeScript

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