mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 13:41:30 +00:00
213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { MsgContext } from "../auto-reply/templating.js";
|
|
import { typedCases } from "../test-utils/typed-cases.js";
|
|
import {
|
|
type ChannelMatchSource,
|
|
buildChannelKeyCandidates,
|
|
normalizeChannelSlug,
|
|
resolveChannelEntryMatch,
|
|
resolveChannelEntryMatchWithFallback,
|
|
resolveNestedAllowlistDecision,
|
|
applyChannelMatchMeta,
|
|
resolveChannelMatchConfig,
|
|
} from "./channel-config.js";
|
|
import { validateSenderIdentity } from "./sender-identity.js";
|
|
|
|
describe("buildChannelKeyCandidates", () => {
|
|
it("dedupes and trims keys", () => {
|
|
expect(buildChannelKeyCandidates(" a ", "a", "", "b", "b")).toEqual(["a", "b"]);
|
|
});
|
|
});
|
|
|
|
describe("normalizeChannelSlug", () => {
|
|
it("normalizes names into slugs", () => {
|
|
expect(normalizeChannelSlug("My Team")).toBe("my-team");
|
|
expect(normalizeChannelSlug("#General Chat")).toBe("general-chat");
|
|
expect(normalizeChannelSlug(" Dev__Chat ")).toBe("dev-chat");
|
|
});
|
|
});
|
|
|
|
describe("resolveChannelEntryMatch", () => {
|
|
it("returns matched entry and wildcard metadata", () => {
|
|
const entries = { a: { allow: true }, "*": { allow: false } };
|
|
const match = resolveChannelEntryMatch({
|
|
entries,
|
|
keys: ["missing", "a"],
|
|
wildcardKey: "*",
|
|
});
|
|
expect(match.entry).toBe(entries.a);
|
|
expect(match.key).toBe("a");
|
|
expect(match.wildcardEntry).toBe(entries["*"]);
|
|
expect(match.wildcardKey).toBe("*");
|
|
});
|
|
});
|
|
|
|
describe("resolveChannelEntryMatchWithFallback", () => {
|
|
const fallbackCases = typedCases<{
|
|
name: string;
|
|
entries: Record<string, { allow: boolean }>;
|
|
args: {
|
|
keys: string[];
|
|
parentKeys?: string[];
|
|
wildcardKey?: string;
|
|
};
|
|
expectedEntryKey: string;
|
|
expectedSource: ChannelMatchSource;
|
|
expectedMatchKey: string;
|
|
}>([
|
|
{
|
|
name: "prefers direct matches over parent and wildcard",
|
|
entries: { a: { allow: true }, parent: { allow: false }, "*": { allow: false } },
|
|
args: { keys: ["a"], parentKeys: ["parent"], wildcardKey: "*" },
|
|
expectedEntryKey: "a",
|
|
expectedSource: "direct",
|
|
expectedMatchKey: "a",
|
|
},
|
|
{
|
|
name: "falls back to parent when direct misses",
|
|
entries: { parent: { allow: false }, "*": { allow: true } },
|
|
args: { keys: ["missing"], parentKeys: ["parent"], wildcardKey: "*" },
|
|
expectedEntryKey: "parent",
|
|
expectedSource: "parent",
|
|
expectedMatchKey: "parent",
|
|
},
|
|
{
|
|
name: "falls back to wildcard when no direct or parent match",
|
|
entries: { "*": { allow: true } },
|
|
args: { keys: ["missing"], parentKeys: ["still-missing"], wildcardKey: "*" },
|
|
expectedEntryKey: "*",
|
|
expectedSource: "wildcard",
|
|
expectedMatchKey: "*",
|
|
},
|
|
]);
|
|
|
|
it.each(fallbackCases)("$name", (testCase) => {
|
|
const match = resolveChannelEntryMatchWithFallback({
|
|
entries: testCase.entries,
|
|
...testCase.args,
|
|
});
|
|
expect(match.entry).toBe(testCase.entries[testCase.expectedEntryKey]);
|
|
expect(match.matchSource).toBe(testCase.expectedSource);
|
|
expect(match.matchKey).toBe(testCase.expectedMatchKey);
|
|
});
|
|
|
|
it("matches normalized keys when normalizeKey is provided", () => {
|
|
const entries = { "My Team": { allow: true } };
|
|
const match = resolveChannelEntryMatchWithFallback({
|
|
entries,
|
|
keys: ["my-team"],
|
|
normalizeKey: normalizeChannelSlug,
|
|
});
|
|
expect(match.entry).toBe(entries["My Team"]);
|
|
expect(match.matchSource).toBe("direct");
|
|
expect(match.matchKey).toBe("My Team");
|
|
});
|
|
});
|
|
|
|
describe("applyChannelMatchMeta", () => {
|
|
it("copies match metadata onto resolved configs", () => {
|
|
const base: { matchKey?: string; matchSource?: ChannelMatchSource } = {};
|
|
const resolved = applyChannelMatchMeta(base, { matchKey: "general", matchSource: "direct" });
|
|
expect(resolved.matchKey).toBe("general");
|
|
expect(resolved.matchSource).toBe("direct");
|
|
});
|
|
});
|
|
|
|
describe("resolveChannelMatchConfig", () => {
|
|
it("returns null when no entry is matched", () => {
|
|
const resolved = resolveChannelMatchConfig({ matchKey: "x" }, () => {
|
|
const out: { matchKey?: string; matchSource?: ChannelMatchSource } = {};
|
|
return out;
|
|
});
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("resolves entry and applies match metadata", () => {
|
|
const resolved = resolveChannelMatchConfig(
|
|
{ entry: { allow: true }, matchKey: "*", matchSource: "wildcard" },
|
|
() => {
|
|
const out: { matchKey?: string; matchSource?: ChannelMatchSource } = {};
|
|
return out;
|
|
},
|
|
);
|
|
expect(resolved?.matchKey).toBe("*");
|
|
expect(resolved?.matchSource).toBe("wildcard");
|
|
});
|
|
});
|
|
|
|
describe("validateSenderIdentity", () => {
|
|
it("allows direct messages without sender fields", () => {
|
|
const ctx: MsgContext = { ChatType: "direct" };
|
|
expect(validateSenderIdentity(ctx)).toEqual([]);
|
|
});
|
|
|
|
it("requires some sender identity for non-direct chats", () => {
|
|
const ctx: MsgContext = { ChatType: "group" };
|
|
expect(validateSenderIdentity(ctx)).toContain(
|
|
"missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)",
|
|
);
|
|
});
|
|
|
|
it("validates SenderE164 and SenderUsername shape", () => {
|
|
const ctx: MsgContext = {
|
|
ChatType: "group",
|
|
SenderE164: "123",
|
|
SenderUsername: "@ada lovelace",
|
|
};
|
|
expect(validateSenderIdentity(ctx)).toEqual([
|
|
"invalid SenderE164: 123",
|
|
'SenderUsername should not include "@": @ada lovelace',
|
|
"SenderUsername should not include whitespace: @ada lovelace",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("resolveNestedAllowlistDecision", () => {
|
|
const cases = [
|
|
{
|
|
name: "allows when outer allowlist is disabled",
|
|
value: {
|
|
outerConfigured: false,
|
|
outerMatched: false,
|
|
innerConfigured: false,
|
|
innerMatched: false,
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "blocks when outer allowlist is configured but missing match",
|
|
value: {
|
|
outerConfigured: true,
|
|
outerMatched: false,
|
|
innerConfigured: false,
|
|
innerMatched: false,
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "requires inner match when inner allowlist is configured",
|
|
value: {
|
|
outerConfigured: true,
|
|
outerMatched: true,
|
|
innerConfigured: true,
|
|
innerMatched: false,
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "allows when both outer and inner allowlists match",
|
|
value: {
|
|
outerConfigured: true,
|
|
outerMatched: true,
|
|
innerConfigured: true,
|
|
innerMatched: true,
|
|
},
|
|
expected: true,
|
|
},
|
|
] as const;
|
|
|
|
it.each(cases)("$name", ({ value, expected }) => {
|
|
expect(resolveNestedAllowlistDecision(value)).toBe(expected);
|
|
});
|
|
});
|