mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 07:02:54 +00:00
242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
decideChannelIngress,
|
|
resolveChannelIngressState,
|
|
type ChannelIngressPolicyInput,
|
|
type ChannelIngressStateInput,
|
|
type InternalChannelIngressAdapter,
|
|
type InternalChannelIngressSubject,
|
|
} from "./index.js";
|
|
|
|
const subject = (value: string): InternalChannelIngressSubject => ({
|
|
identifiers: [{ opaqueId: "subject-1", kind: "stable-id", value }],
|
|
});
|
|
|
|
const adapter: InternalChannelIngressAdapter = {
|
|
normalizeEntries({ entries }) {
|
|
return {
|
|
matchable: entries.map((entry, index) => ({
|
|
opaqueEntryId: `entry-${index + 1}`,
|
|
kind: "stable-id",
|
|
value: entry,
|
|
dangerous: entry.startsWith("display:"),
|
|
})),
|
|
invalid: [],
|
|
disabled: [],
|
|
};
|
|
},
|
|
matchSubject({ subject, entries }) {
|
|
const values = new Set(subject.identifiers.map((identifier) => identifier.value));
|
|
const matchedEntryIds = entries
|
|
.filter((entry) => entry.value === "*" || values.has(entry.value))
|
|
.map((entry) => entry.opaqueEntryId);
|
|
return { matched: matchedEntryIds.length > 0, matchedEntryIds };
|
|
},
|
|
};
|
|
|
|
const lowerCaseAdapter: InternalChannelIngressAdapter = {
|
|
normalizeEntries({ entries }) {
|
|
return {
|
|
matchable: entries.map((entry, index) => ({
|
|
opaqueEntryId: `entry-${index + 1}`,
|
|
kind: "stable-id",
|
|
value: entry.toLowerCase(),
|
|
})),
|
|
invalid: [],
|
|
disabled: [],
|
|
};
|
|
},
|
|
matchSubject({ subject, entries }) {
|
|
const values = new Set(subject.identifiers.map((identifier) => identifier.value.toLowerCase()));
|
|
const matchedEntryIds = entries
|
|
.filter((entry) => entry.kind === "stable-id" && values.has(entry.value))
|
|
.map((entry) => entry.opaqueEntryId);
|
|
return { matched: matchedEntryIds.length > 0, matchedEntryIds };
|
|
},
|
|
};
|
|
|
|
function baseInput(overrides: Partial<ChannelIngressStateInput> = {}): ChannelIngressStateInput {
|
|
return {
|
|
channelId: "test",
|
|
accountId: "default",
|
|
subject: subject("sender-1"),
|
|
conversation: { kind: "direct", id: "dm-1" },
|
|
adapter,
|
|
event: { kind: "message", authMode: "inbound", mayPair: true },
|
|
allowlists: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const policy: ChannelIngressPolicyInput = {
|
|
dmPolicy: "pairing",
|
|
groupPolicy: "allowlist",
|
|
};
|
|
|
|
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
|
if (!record || typeof record !== "object") {
|
|
throw new Error("Expected record");
|
|
}
|
|
const actual = record as Record<string, unknown>;
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
expect(actual[key]).toEqual(value);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
describe("channel message access ingress", () => {
|
|
it.each([
|
|
{
|
|
name: "keeps pairing-store entries DM-policy scoped",
|
|
input: baseInput({
|
|
subject: subject("paired-sender"),
|
|
allowlists: { pairingStore: ["paired-sender"] },
|
|
}),
|
|
policy: { ...policy, dmPolicy: "open" as const },
|
|
expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" },
|
|
secondPolicy: { ...policy, dmPolicy: "pairing" as const },
|
|
secondExpected: { admission: "dispatch", decision: "allow" },
|
|
},
|
|
{
|
|
name: "requires explicit group fallback to DM allowlists",
|
|
input: baseInput({
|
|
conversation: { kind: "group", id: "room-1" },
|
|
allowlists: { dm: ["sender-1"] },
|
|
}),
|
|
policy,
|
|
expected: { admission: "drop", reasonCode: "group_policy_empty_allowlist" },
|
|
secondPolicy: { ...policy, groupAllowFromFallbackToAllowFrom: true },
|
|
secondExpected: { admission: "dispatch", decision: "allow" },
|
|
},
|
|
{
|
|
name: "requires explicit dangerous identifier matching",
|
|
input: baseInput({
|
|
subject: subject("display:sender-1"),
|
|
allowlists: { dm: ["display:sender-1"] },
|
|
}),
|
|
policy: { ...policy, dmPolicy: "allowlist" as const },
|
|
expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" },
|
|
secondPolicy: {
|
|
...policy,
|
|
dmPolicy: "allowlist" as const,
|
|
mutableIdentifierMatching: "enabled" as const,
|
|
},
|
|
secondExpected: { admission: "dispatch", decision: "allow" },
|
|
},
|
|
])("$name", async ({ input, policy, expected, secondPolicy, secondExpected }) => {
|
|
const state = await resolveChannelIngressState(input);
|
|
expectRecordFields(decideChannelIngress(state, policy), expected);
|
|
expectRecordFields(decideChannelIngress(state, secondPolicy), secondExpected);
|
|
});
|
|
|
|
it("applies route sender allowlists without retaining raw sender values", async () => {
|
|
const rawSender = "route-sender@example.test";
|
|
const state = await resolveChannelIngressState(
|
|
baseInput({
|
|
subject: subject(rawSender),
|
|
conversation: { kind: "group", id: "room-1" },
|
|
routeFacts: [
|
|
{
|
|
id: "space-1",
|
|
kind: "route",
|
|
gate: "matched",
|
|
effect: "allow",
|
|
precedence: 0,
|
|
senderPolicy: "replace",
|
|
senderAllowFrom: [rawSender],
|
|
},
|
|
],
|
|
allowlists: { group: ["group-sender"] },
|
|
}),
|
|
);
|
|
|
|
const decision = decideChannelIngress(state, policy);
|
|
|
|
const senderAllowlist = expectRecordFields(state.routeFacts[0]?.senderAllowlist, {
|
|
hasConfiguredEntries: true,
|
|
});
|
|
expectRecordFields(senderAllowlist.match, { matched: true });
|
|
expectRecordFields(decision, { admission: "dispatch", decision: "allow" });
|
|
expect(JSON.stringify(state)).not.toContain(rawSender);
|
|
expect(JSON.stringify(decision)).not.toContain(rawSender);
|
|
});
|
|
|
|
it("blocks matched routes with deny-when-empty sender policy", async () => {
|
|
const state = await resolveChannelIngressState(
|
|
baseInput({
|
|
routeFacts: [
|
|
{
|
|
id: "space-1",
|
|
kind: "route",
|
|
gate: "matched",
|
|
effect: "allow",
|
|
precedence: 0,
|
|
senderPolicy: "deny-when-empty",
|
|
senderAllowFrom: [],
|
|
},
|
|
],
|
|
allowlists: { dm: ["sender-1"] },
|
|
}),
|
|
);
|
|
|
|
expectRecordFields(decideChannelIngress(state, policy), {
|
|
admission: "drop",
|
|
reasonCode: "route_sender_empty",
|
|
});
|
|
expect(state.routeFacts[0]).not.toHaveProperty("senderAllowFrom");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "allows origin-subject events for the same normalized actor",
|
|
adapter,
|
|
current: "sender-1",
|
|
origin: "sender-1",
|
|
matched: true,
|
|
expected: { admission: "dispatch", decision: "allow" },
|
|
},
|
|
{
|
|
name: "does not authorize by default opaque identifier slots",
|
|
adapter,
|
|
current: "sender-1",
|
|
origin: "different-sender",
|
|
matched: false,
|
|
expected: { admission: "drop", decision: "block", reasonCode: "origin_subject_not_matched" },
|
|
},
|
|
{
|
|
name: "uses adapter-normalized identity values",
|
|
adapter: lowerCaseAdapter,
|
|
current: "Sender-1",
|
|
origin: "sender-1",
|
|
matched: true,
|
|
expected: { admission: "dispatch", decision: "allow" },
|
|
},
|
|
])("$name", async (entry) => {
|
|
const state = await resolveChannelIngressState(
|
|
baseInput({
|
|
adapter: entry.adapter,
|
|
subject: subject(entry.current),
|
|
event: {
|
|
kind: "reaction",
|
|
authMode: "origin-subject",
|
|
mayPair: false,
|
|
originSubject: subject(entry.origin),
|
|
},
|
|
}),
|
|
);
|
|
const decision = decideChannelIngress(state, policy);
|
|
|
|
expect(state.event.originSubjectMatched).toBe(entry.matched);
|
|
expectRecordFields(decision, entry.expected);
|
|
if (entry.matched) {
|
|
const gate = decision.graph.gates.find(
|
|
(gate) => gate.phase === "sender" && gate.kind === "dmSender",
|
|
);
|
|
expectRecordFields(gate, {
|
|
effect: "ignore",
|
|
reasonCode: "sender_not_required",
|
|
});
|
|
}
|
|
});
|
|
});
|