Files
Moltbot/src/security/dm-policy-shared.test.ts
2026-03-08 00:05:24 +00:00

484 lines
16 KiB
TypeScript

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<ParityCase> & Pick<ParityCase, "name">): 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)",
});
});
}
});