import { describe, expect, it } from "vitest"; import { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, resolveDmAllowState, resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessDecision, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, resolvePinnedMainDmOwnerFromAllowlist, } from "./dm-policy-shared.js"; describe("security/dm-policy-shared", () => { const controlCommand = { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true, } as const; async function expectStoreReadSkipped(params: { provider: string; accountId: string; dmPolicy?: "open" | "allowlist" | "pairing" | "disabled"; shouldRead?: boolean; }) { let called = false; const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: params.provider, accountId: params.accountId, ...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}), ...(params.shouldRead !== undefined ? { shouldRead: params.shouldRead } : {}), readStore: async (_provider, _accountId) => { called = true; return ["should-not-be-read"]; }, }); expect(called).toBe(false); expect(storeAllowFrom).toEqual([]); } function resolveCommandGate(overrides: { isGroup: boolean; isSenderAllowed: (allowFrom: string[]) => boolean; groupPolicy?: "open" | "allowlist" | "disabled"; }) { return resolveDmGroupAccessWithCommandGate({ dmPolicy: "pairing", groupPolicy: overrides.groupPolicy ?? "allowlist", allowFrom: ["owner"], groupAllowFrom: ["group-owner"], storeAllowFrom: ["paired-user"], command: controlCommand, ...overrides, }); } it("normalizes config + store allow entries and counts distinct senders", async () => { const state = await resolveDmAllowState({ provider: "telegram", accountId: "default", allowFrom: [" * ", " alice ", "ALICE", "bob"], normalizeEntry: (value) => value.toLowerCase(), readStore: async (_provider, _accountId) => [" Bob ", "carol", ""], }); expect(state.configAllowFrom).toEqual(["*", "alice", "ALICE", "bob"]); expect(state.hasWildcard).toBe(true); expect(state.allowCount).toBe(3); expect(state.isMultiUserDm).toBe(true); }); it("handles empty allowlists and store failures", async () => { const state = await resolveDmAllowState({ provider: "slack", accountId: "default", allowFrom: undefined, readStore: async (_provider, _accountId) => { throw new Error("offline"); }, }); expect(state.configAllowFrom).toEqual([]); expect(state.hasWildcard).toBe(false); expect(state.allowCount).toBe(0); expect(state.isMultiUserDm).toBe(false); }); it("skips pairing-store reads when dmPolicy is allowlist", async () => { await expectStoreReadSkipped({ provider: "telegram", accountId: "default", dmPolicy: "allowlist", }); }); it("skips pairing-store reads when shouldRead=false", async () => { await expectStoreReadSkipped({ provider: "slack", accountId: "default", shouldRead: false, }); }); it("builds effective DM/group allowlists from config + pairing store", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: [" owner ", "", "owner2"], groupAllowFrom: ["group:abc"], storeAllowFrom: [" owner3 ", ""], }); expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]); expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); }); it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: [" owner "], groupAllowFrom: [], storeAllowFrom: [" owner2 "], }); expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]); expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]); }); it("can keep group allowlist empty when fallback is disabled", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: ["owner"], groupAllowFrom: [], storeAllowFrom: ["paired-user"], groupAllowFromFallbackToAllowFrom: false, }); expect(lists.effectiveAllowFrom).toEqual(["owner", "paired-user"]); expect(lists.effectiveGroupAllowFrom).toEqual([]); }); it("infers pinned main DM owner from a single configured allowlist entry", () => { const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ dmScope: "main", allowFrom: [" line:user:U123 "], normalizeEntry: (entry) => entry .trim() .toLowerCase() .replace(/^line:(?:user:)?/, ""), }); expect(pinnedOwner).toBe("u123"); }); it("does not infer pinned owner for wildcard/multi-owner/non-main scope", () => { expect( resolvePinnedMainDmOwnerFromAllowlist({ dmScope: "main", allowFrom: ["*"], normalizeEntry: (entry) => entry.trim(), }), ).toBeNull(); expect( resolvePinnedMainDmOwnerFromAllowlist({ dmScope: "main", allowFrom: ["u123", "u456"], normalizeEntry: (entry) => entry.trim(), }), ).toBeNull(); expect( resolvePinnedMainDmOwnerFromAllowlist({ dmScope: "per-channel-peer", allowFrom: ["u123"], normalizeEntry: (entry) => entry.trim(), }), ).toBeNull(); }); it("excludes storeAllowFrom when dmPolicy is allowlist", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: ["+1111"], groupAllowFrom: ["group:abc"], storeAllowFrom: ["+2222", "+3333"], dmPolicy: "allowlist", }); expect(lists.effectiveAllowFrom).toEqual(["+1111"]); expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]); }); it("keeps group allowlist explicit when dmPolicy is pairing", () => { const lists = resolveEffectiveAllowFromLists({ allowFrom: ["+1111"], groupAllowFrom: [], storeAllowFrom: ["+2222"], dmPolicy: "pairing", }); expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]); expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]); }); it("resolves access + effective allowlists in one shared call", () => { const resolved = resolveDmGroupAccessWithLists({ isGroup: false, dmPolicy: "pairing", groupPolicy: "allowlist", allowFrom: ["owner"], groupAllowFrom: ["group:room"], storeAllowFrom: ["paired-user"], isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), }); expect(resolved.decision).toBe("allow"); expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED); expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)"); expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]); expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); }); it("resolves command gate with dm/group parity for groups", () => { const resolved = resolveCommandGate({ isGroup: true, isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), }); expect(resolved.decision).toBe("block"); expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)"); expect(resolved.commandAuthorized).toBe(false); expect(resolved.shouldBlockControlCommand).toBe(true); }); it("keeps configured dm allowlist usable for group command auth", () => { const resolved = resolveDmGroupAccessWithCommandGate({ isGroup: true, dmPolicy: "pairing", groupPolicy: "open", allowFrom: ["owner"], groupAllowFrom: [], storeAllowFrom: ["paired-user"], isSenderAllowed: (allowFrom) => allowFrom.includes("owner"), command: controlCommand, }); expect(resolved.commandAuthorized).toBe(true); expect(resolved.shouldBlockControlCommand).toBe(false); }); it("treats dm command authorization as dm access result", () => { const resolved = resolveCommandGate({ isGroup: false, isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), }); expect(resolved.decision).toBe("allow"); expect(resolved.commandAuthorized).toBe(true); expect(resolved.shouldBlockControlCommand).toBe(false); }); it("does not auto-authorize dm commands in open mode without explicit allowlists", () => { const resolved = resolveDmGroupAccessWithCommandGate({ isGroup: false, dmPolicy: "open", groupPolicy: "allowlist", allowFrom: [], groupAllowFrom: [], storeAllowFrom: [], isSenderAllowed: () => false, command: controlCommand, }); expect(resolved.decision).toBe("allow"); expect(resolved.commandAuthorized).toBe(false); expect(resolved.shouldBlockControlCommand).toBe(false); }); it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { const resolved = resolveDmGroupAccessWithLists({ isGroup: false, dmPolicy: "allowlist", groupPolicy: "allowlist", allowFrom: ["owner"], groupAllowFrom: [], storeAllowFrom: ["paired-user"], isSenderAllowed: () => false, }); expect(resolved.decision).toBe("block"); expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED); expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)"); expect(resolved.effectiveAllowFrom).toEqual(["owner"]); }); const channels = [ "bluebubbles", "imessage", "signal", "telegram", "whatsapp", "msteams", "matrix", "zalo", ] as const; type ParityCase = { name: string; isGroup: boolean; dmPolicy: "open" | "allowlist" | "pairing" | "disabled"; groupPolicy: "open" | "allowlist" | "disabled"; allowFrom: string[]; groupAllowFrom: string[]; storeAllowFrom: string[]; isSenderAllowed: (allowFrom: string[]) => boolean; expectedDecision: "allow" | "block" | "pairing"; expectedReactionAllowed: boolean; }; function createParityCase({ name, ...overrides }: Partial & Pick): ParityCase { return { name, isGroup: false, dmPolicy: "open", groupPolicy: "allowlist", allowFrom: [], groupAllowFrom: [], storeAllowFrom: [], isSenderAllowed: () => false, expectedDecision: "allow", expectedReactionAllowed: true, ...overrides, }; } it("keeps message/reaction policy parity table across channels", () => { const cases = [ createParityCase({ name: "dmPolicy=open", dmPolicy: "open", expectedDecision: "allow", expectedReactionAllowed: true, }), createParityCase({ name: "dmPolicy=disabled", dmPolicy: "disabled", expectedDecision: "block", expectedReactionAllowed: false, }), createParityCase({ name: "dmPolicy=allowlist unauthorized", dmPolicy: "allowlist", allowFrom: ["owner"], isSenderAllowed: () => false, expectedDecision: "block", expectedReactionAllowed: false, }), createParityCase({ name: "dmPolicy=allowlist authorized", dmPolicy: "allowlist", allowFrom: ["owner"], isSenderAllowed: () => true, expectedDecision: "allow", expectedReactionAllowed: true, }), createParityCase({ name: "dmPolicy=pairing unauthorized", dmPolicy: "pairing", isSenderAllowed: () => false, expectedDecision: "pairing", expectedReactionAllowed: false, }), createParityCase({ name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list", isGroup: true, dmPolicy: "pairing", allowFrom: ["owner"], groupAllowFrom: ["group-owner"], storeAllowFrom: ["paired-user"], isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"), expectedDecision: "block", expectedReactionAllowed: false, }), ]; for (const channel of channels) { for (const testCase of cases) { const access = resolveDmGroupAccessWithLists({ isGroup: testCase.isGroup, dmPolicy: testCase.dmPolicy, groupPolicy: testCase.groupPolicy, allowFrom: testCase.allowFrom, groupAllowFrom: testCase.groupAllowFrom, storeAllowFrom: testCase.storeAllowFrom, isSenderAllowed: testCase.isSenderAllowed, }); const reactionAllowed = access.decision === "allow"; expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision); expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe( testCase.expectedReactionAllowed, ); } } }); for (const channel of channels) { it(`[${channel}] blocks groups when group allowlist is empty`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: true, dmPolicy: "pairing", groupPolicy: "allowlist", effectiveAllowFrom: ["owner"], effectiveGroupAllowFrom: [], isSenderAllowed: () => false, }); expect(decision).toEqual({ decision: "block", reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, reason: "groupPolicy=allowlist (empty allowlist)", }); }); it(`[${channel}] allows groups when group policy is open`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: true, dmPolicy: "pairing", groupPolicy: "open", effectiveAllowFrom: ["owner"], effectiveGroupAllowFrom: [], isSenderAllowed: () => false, }); expect(decision).toEqual({ decision: "allow", reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED, reason: "groupPolicy=open", }); }); it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: false, dmPolicy: "allowlist", groupPolicy: "allowlist", effectiveAllowFrom: [], effectiveGroupAllowFrom: [], isSenderAllowed: () => false, }); expect(decision).toEqual({ decision: "block", reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, reason: "dmPolicy=allowlist (not allowlisted)", }); }); it(`[${channel}] uses pairing flow when DM sender is not allowlisted`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: false, dmPolicy: "pairing", groupPolicy: "allowlist", effectiveAllowFrom: [], effectiveGroupAllowFrom: [], isSenderAllowed: () => false, }); expect(decision).toEqual({ decision: "pairing", reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED, reason: "dmPolicy=pairing (not allowlisted)", }); }); it(`[${channel}] allows DM sender when allowlisted`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: false, dmPolicy: "allowlist", groupPolicy: "allowlist", effectiveAllowFrom: ["owner"], effectiveGroupAllowFrom: [], isSenderAllowed: () => true, }); expect(decision.decision).toBe("allow"); }); it(`[${channel}] blocks group allowlist mode when sender/group is not allowlisted`, () => { const decision = resolveDmGroupAccessDecision({ isGroup: true, dmPolicy: "pairing", groupPolicy: "allowlist", effectiveAllowFrom: ["owner"], effectiveGroupAllowFrom: ["group:abc"], isSenderAllowed: () => false, }); expect(decision).toEqual({ decision: "block", reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, reason: "groupPolicy=allowlist (not allowlisted)", }); }); } });