Files
openclaw/src/pairing/pairing-store.test.ts
T
Peter Steinberger 0c99a4e362 feat(ui): manage DM pairing requests in Channels (#112401)
* feat(ui): manage DM pairing requests

* fix(ui): clear pairing data across auth changes

* test(ui): tighten pairing page fixture type

* fix(gateway): complete pairing protocol contracts

* fix(ui): guard pairing mutations across epochs

* fix(ui): restore chat teardown gates

* fix(ui): isolate channel auth lifecycles

* fix(ui): remove stale chat view export
2026-07-22 04:54:20 -07:00

450 lines
14 KiB
TypeScript

// Tests SQLite-backed pairing store lifecycle and account isolation.
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
const pairingMocks = vi.hoisted(() => ({
getPairingAdapter: vi.fn<
() => { idLabel: string; normalizeAllowEntry?: (entry: string) => string } | null
>(() => null),
}));
vi.mock("../channels/plugins/pairing.js", () => ({
getPairingAdapter: pairingMocks.getPairingAdapter,
}));
import {
readChannelPairingStateSnapshot,
writeChannelPairingStateSnapshot,
} from "./pairing-store-sqlite.test-helpers.js";
import {
addChannelAllowFromStoreEntry,
approveChannelPairingCode,
approveChannelPairingRequest,
dismissChannelPairingRequest,
listChannelPairingRequests,
readChannelAllowFromStore,
readChannelAllowFromStoreSync,
removeChannelAllowFromStoreEntry,
resolveChannelPairingRequestId,
upsertChannelPairingRequest,
} from "./pairing-store.js";
type PairingTestDatabase = Pick<
OpenClawStateKyselyDatabase,
"channel_pairing_allow_entries" | "channel_pairing_requests"
>;
let fixtureRoot = "";
let caseId = 0;
type RandomIntSync = (minOrMax: number, max?: number) => number;
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pairing-"));
});
afterAll(() => {
closeOpenClawStateDatabaseForTest();
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
pairingMocks.getPairingAdapter.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
closeOpenClawStateDatabaseForTest();
});
function createTestEnv(): { stateDir: string; env: NodeJS.ProcessEnv } {
const stateDir = path.join(fixtureRoot, `case-${caseId++}`);
fs.mkdirSync(stateDir, { recursive: true });
return { stateDir, env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } };
}
function requireFirstPairingRequest(
requests: Awaited<ReturnType<typeof listChannelPairingRequests>>,
) {
expect(requests).toHaveLength(1);
const request = requests[0];
if (!request) {
throw new Error("expected pairing request");
}
return request;
}
async function withMockRandomInt(params: {
initialValue?: number;
sequence?: number[];
fallbackValue?: number;
run: () => Promise<void>;
}) {
const spy = vi.spyOn(crypto, "randomInt") as unknown as {
mockImplementation: (impl: RandomIntSync) => void;
mockReturnValue: (value: number) => void;
mockRestore: () => void;
};
try {
if (params.initialValue !== undefined) {
spy.mockReturnValue(params.initialValue);
}
if (params.sequence) {
let index = 0;
spy.mockImplementation(() => params.sequence?.[index++] ?? params.fallbackValue ?? 1);
}
await params.run();
} finally {
spy.mockRestore();
}
}
function writeAllowFromFixture(params: {
env: NodeJS.ProcessEnv;
channel: string;
accountId?: string;
allowFrom: string[];
}) {
const state = readChannelPairingStateSnapshot(params.channel, params.env);
state.allowFrom ??= {};
state.allowFrom[params.accountId ?? DEFAULT_ACCOUNT_ID] = params.allowFrom;
writeChannelPairingStateSnapshot(params.channel, state, params.env);
}
describe("pairing store", () => {
it("normalizes allowlist entries through channel pairing adapters", async () => {
const { env } = createTestEnv();
pairingMocks.getPairingAdapter.mockReturnValue({
idLabel: "Telegram user",
normalizeAllowEntry: (entry) => entry.replace(/^telegram:/i, ""),
});
await expect(
addChannelAllowFromStoreEntry({
channel: "telegram",
accountId: "yy",
entry: "telegram:1001",
env,
}),
).resolves.toEqual({ changed: true, allowFrom: ["1001"] });
await expect(readChannelAllowFromStore("telegram", env, "yy")).resolves.toEqual(["1001"]);
const directAdapter = {
idLabel: "Direct",
normalizeAllowEntry: (entry: string) => entry.replace(/^direct:/i, ""),
};
await expect(
addChannelAllowFromStoreEntry({
channel: "external-channel",
accountId: "main",
entry: "direct:42",
env,
pairingAdapter: directAdapter,
}),
).resolves.toEqual({ changed: true, allowFrom: ["42"] });
});
it("skips malformed persisted requests while approving valid codes", async () => {
const { env } = createTestEnv();
const database = openOpenClawStateDatabase({ env });
const db = getNodeSqliteKysely<PairingTestDatabase>(database.db);
executeSqliteQuerySync(
database.db,
db.insertInto("channel_pairing_requests").values([
{
channel_key: "telegram",
account_id: DEFAULT_ACCOUNT_ID,
request_id: "",
code: "BADCODE1",
created_at: "invalid",
last_seen_at: "invalid",
meta_json: null,
},
{
channel_key: "telegram",
account_id: "alpha",
request_id: "valid-user",
code: "GOODCODE",
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
meta_json: JSON.stringify({ accountId: "stale-account" }),
},
]),
);
await expect(listChannelPairingRequests("telegram", env, "alpha")).resolves.toHaveLength(1);
await expect(listChannelPairingRequests("telegram", env, "stale-account")).resolves.toEqual([]);
await expect(
approveChannelPairingCode({ channel: "telegram", accountId: "alpha", code: "GOODCODE", env }),
).resolves.toMatchObject({ id: "valid-user" });
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual([
"valid-user",
]);
await expect(readChannelAllowFromStore("telegram", env, "stale-account")).resolves.toEqual([]);
});
it("handles pending request reuse, expiry, and per-account limits", async () => {
const { env } = createTestEnv();
const first = await upsertChannelPairingRequest({
channel: "demo-a",
id: "u1",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
const reused = await upsertChannelPairingRequest({
channel: "demo-a",
id: "u1",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
expect(reused).toEqual({ code: first.code, created: false });
const expired = await upsertChannelPairingRequest({
channel: "demo-b",
id: "expired",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
expect(expired.created).toBe(true);
const state = readChannelPairingStateSnapshot("demo-b", env);
const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
state.requests = state.requests.map((request) => ({
...request,
createdAt: expiredAt,
lastSeenAt: expiredAt,
}));
writeChannelPairingStateSnapshot("demo-b", state, env);
await expect(listChannelPairingRequests("demo-b", env)).resolves.toEqual([]);
for (const id of ["one", "two", "three"]) {
await expect(
upsertChannelPairingRequest({
channel: "demo-c",
id,
accountId: DEFAULT_ACCOUNT_ID,
env,
}),
).resolves.toMatchObject({ created: true });
}
await expect(
upsertChannelPairingRequest({
channel: "demo-c",
id: "four",
accountId: DEFAULT_ACCOUNT_ID,
env,
}),
).resolves.toEqual({ code: "", created: false });
});
it("persists a channel-derived approval entry from request metadata", async () => {
const { env } = createTestEnv();
const request = await upsertChannelPairingRequest({
channel: "demo-a",
id: "alice",
accountId: DEFAULT_ACCOUNT_ID,
meta: { proofEntry: "fixture-entry" },
env,
});
const pairingAdapter = {
idLabel: "peer",
normalizeAllowEntry: (entry: string) => entry,
resolveApprovalStoreEntry: ({ meta }: { meta?: Record<string, string> }) =>
meta?.proofEntry ?? null,
};
await expect(
approveChannelPairingCode({
channel: "demo-a",
code: request.code,
env,
pairingAdapter,
}),
).resolves.toMatchObject({ id: "alice" });
await expect(readChannelAllowFromStore("demo-a", env)).resolves.toEqual(["fixture-entry"]);
});
it("approves and dismisses account-scoped requests by opaque id", async () => {
const { env } = createTestEnv();
await upsertChannelPairingRequest({
channel: "telegram",
accountId: "alpha",
id: "shared-sender",
env,
});
await upsertChannelPairingRequest({
channel: "telegram",
accountId: "beta",
id: "shared-sender",
env,
});
const alphaRequest = requireFirstPairingRequest(
await listChannelPairingRequests("telegram", env, "alpha"),
);
const betaRequest = requireFirstPairingRequest(
await listChannelPairingRequests("telegram", env, "beta"),
);
const alphaRequestId = resolveChannelPairingRequestId("telegram", alphaRequest);
const betaRequestId = resolveChannelPairingRequestId("telegram", betaRequest);
expect(alphaRequestId).not.toBe(betaRequestId);
expect(alphaRequestId).not.toContain(alphaRequest.code);
await expect(
approveChannelPairingRequest({
channel: "telegram",
accountId: "alpha",
requestId: alphaRequestId,
env,
}),
).resolves.toMatchObject({ id: "shared-sender" });
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual([
"shared-sender",
]);
await expect(
approveChannelPairingRequest({
channel: "telegram",
accountId: "beta",
requestId: alphaRequestId,
env,
}),
).resolves.toBeNull();
await expect(
dismissChannelPairingRequest({
channel: "telegram",
accountId: "beta",
requestId: betaRequestId,
env,
}),
).resolves.toMatchObject({ id: "shared-sender" });
await expect(readChannelAllowFromStore("telegram", env, "beta")).resolves.toEqual([]);
await expect(listChannelPairingRequests("telegram", env)).resolves.toEqual([]);
});
it("regenerates colliding codes and reports exhaustion without leaking codes", async () => {
const { env } = createTestEnv();
await withMockRandomInt({
initialValue: 0,
run: async () => {
const first = await upsertChannelPairingRequest({
channel: "telegram",
id: "123",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
expect(first.code).toBe("AAAAAAAA");
await withMockRandomInt({
sequence: Array(8).fill(0).concat(Array(8).fill(1)),
fallbackValue: 1,
run: async () => {
await expect(
upsertChannelPairingRequest({
channel: "telegram",
id: "456",
accountId: DEFAULT_ACCOUNT_ID,
env,
}),
).resolves.toMatchObject({ code: "BBBBBBBB" });
},
});
},
});
const second = createTestEnv();
await withMockRandomInt({
initialValue: 0,
run: async () => {
await upsertChannelPairingRequest({
channel: "telegram",
id: "123",
accountId: DEFAULT_ACCOUNT_ID,
env: second.env,
});
await expect(
upsertChannelPairingRequest({
channel: "telegram",
id: "456",
accountId: DEFAULT_ACCOUNT_ID,
env: second.env,
}),
).rejects.toThrow(
"failed to generate unique pairing code after 500 attempts; existing code count: 1",
);
},
});
});
it("keeps allowFrom and pending requests isolated by account", async () => {
const { env } = createTestEnv();
await addChannelAllowFromStoreEntry({
channel: "telegram",
accountId: "alpha",
entry: "1001",
env,
});
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual(["1001"]);
await expect(readChannelAllowFromStore("telegram", env, "beta")).resolves.toEqual([]);
const alpha = await upsertChannelPairingRequest({
channel: "telegram",
accountId: "alpha",
id: "shared",
env,
});
const beta = await upsertChannelPairingRequest({
channel: "telegram",
accountId: "beta",
id: "shared",
env,
});
expect(beta.code).not.toBe(alpha.code);
expect(
requireFirstPairingRequest(await listChannelPairingRequests("telegram", env, "alpha")).code,
).toBe(alpha.code);
expect(
requireFirstPairingRequest(await listChannelPairingRequests("telegram", env, "beta")).code,
).toBe(beta.code);
await expect(
approveChannelPairingCode({ channel: "telegram", code: alpha.code, env }),
).resolves.toMatchObject({ id: "shared" });
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual([
"1001",
"shared",
]);
await expect(readChannelAllowFromStore("telegram", env, "beta")).resolves.toEqual([]);
await expect(
removeChannelAllowFromStoreEntry({
channel: "telegram",
accountId: "alpha",
entry: "1001",
env,
}),
).resolves.toEqual({ changed: true, allowFrom: ["shared"] });
});
it("reads current SQLite entries without a process-local file cache", async () => {
const { env } = createTestEnv();
writeAllowFromFixture({ env, channel: "telegram", accountId: "yy", allowFrom: ["1001"] });
await expect(readChannelAllowFromStore("telegram", env, "yy")).resolves.toEqual(["1001"]);
expect(readChannelAllowFromStoreSync("telegram", env, "yy")).toEqual(["1001"]);
writeAllowFromFixture({ env, channel: "telegram", accountId: "yy", allowFrom: ["10022"] });
await expect(readChannelAllowFromStore("telegram", env, "yy")).resolves.toEqual(["10022"]);
expect(readChannelAllowFromStoreSync("telegram", env, "yy")).toEqual(["10022"]);
});
});