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: "demo-channel-a" as never,
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: "demo-channel-b" as never,
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.each([
{
name: "dmPolicy is allowlist" ,
params: {
provider: "demo-channel-a" ,
accountId: "default" ,
dmPolicy: "allowlist" as const ,
},
},
{
name: "shouldRead=false" ,
params: {
provider: "demo-channel-b" ,
accountId: "default" ,
shouldRead: false ,
},
},
] as const )("skips pairing-store reads when $name" , async ({ params }) => {
await expectStoreReadSkipped(params);
});
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.each([
{
name: "wildcard allowlist" ,
dmScope: "main" as const ,
allowFrom: ["*" ],
},
{
name: "multi-owner allowlist" ,
dmScope: "main" as const ,
allowFrom: ["u123" , "u456" ],
},
{
name: "non-main scope" ,
dmScope: "per-channel-peer" as const ,
allowFrom: ["u123" ],
},
] as const )("does not infer pinned owner for $name" , ({ dmScope, allowFrom }) => {
expect(
resolvePinnedMainDmOwnerFromAllowlist({
dmScope,
allowFrom: [...allowFrom],
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 ;
};
type DecisionCase = {
name: string;
input: Parameters<typeof resolveDmGroupAccessDecision>[0 ];
expected:
| ReturnType<typeof resolveDmGroupAccessDecision>
| Pick<ReturnType<typeof resolveDmGroupAccessDecision>, "decision" >;
};
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,
};
}
function expectParityCase(channel: (typeof channels)[number], testCase: ParityCase) {
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,
);
}
it.each(
channels.flatMap((channel) =>
[
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 ,
}),
].map((testCase) => ({
channel,
testCase,
})),
),
)(
"keeps message/reaction policy parity table across channels: [$channel] $testCase.name" ,
({ channel, testCase }) => {
expectParityCase(channel, testCase);
},
);
const decisionCases: DecisionCase[] = [
{
name: "blocks groups when group allowlist is empty" ,
input: {
isGroup: true ,
dmPolicy: "pairing" ,
groupPolicy: "allowlist" ,
effectiveAllowFrom: ["owner" ],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false ,
},
expected: {
decision: "block" ,
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)" ,
},
},
{
name: "allows groups when group policy is open" ,
input: {
isGroup: true ,
dmPolicy: "pairing" ,
groupPolicy: "open" ,
effectiveAllowFrom: ["owner" ],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false ,
},
expected: {
decision: "allow" ,
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=open" ,
},
},
{
name: "blocks DM allowlist mode when allowlist is empty" ,
input: {
isGroup: false ,
dmPolicy: "allowlist" ,
groupPolicy: "allowlist" ,
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false ,
},
expected: {
decision: "block" ,
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: "dmPolicy=allowlist (not allowlisted)" ,
},
},
{
name: "uses pairing flow when DM sender is not allowlisted" ,
input: {
isGroup: false ,
dmPolicy: "pairing" ,
groupPolicy: "allowlist" ,
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false ,
},
expected: {
decision: "pairing" ,
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
reason: "dmPolicy=pairing (not allowlisted)" ,
},
},
{
name: "allows DM sender when allowlisted" ,
input: {
isGroup: false ,
dmPolicy: "allowlist" ,
groupPolicy: "allowlist" ,
effectiveAllowFrom: ["owner" ],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => true ,
},
expected: {
decision: "allow" ,
},
},
{
name: "blocks group allowlist mode when sender/group is not allowlisted" ,
input: {
isGroup: true ,
dmPolicy: "pairing" ,
groupPolicy: "allowlist" ,
effectiveAllowFrom: ["owner" ],
effectiveGroupAllowFrom: ["group:abc" ],
isSenderAllowed: () => false ,
},
expected: {
decision: "block" ,
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
reason: "groupPolicy=allowlist (not allowlisted)" ,
},
},
];
it.each(
channels.flatMap((channel) =>
decisionCases.map((testCase) => ({
channel,
testCase,
})),
),
)("[$channel] $testCase.name" , ({ testCase }) => {
const decision = resolveDmGroupAccessDecision(testCase.input);
if ("reasonCode" in testCase.expected && "reason" in testCase.expected) {
expect(decision).toEqual(testCase.expected);
return ;
}
expect(decision).toMatchObject(testCase.expected);
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-08)
¤
*© Formatika GbR, Deutschland