mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
test: dedupe plugin sdk helper suites
This commit is contained in:
@@ -31,80 +31,83 @@ function parseAllowTarget(
|
||||
}
|
||||
|
||||
describe("isAllowedParsedChatSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedParsedChatSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
normalizeSender: (sender) => sender,
|
||||
parseAllowTarget,
|
||||
});
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedParsedChatSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "user@example.com",
|
||||
normalizeSender: (sender) => sender.toLowerCase(),
|
||||
parseAllowTarget,
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("matches normalized handles", () => {
|
||||
const allowed = isAllowedParsedChatSender({
|
||||
allowFrom: ["User@Example.com"],
|
||||
sender: "user@example.com",
|
||||
normalizeSender: (sender) => sender.toLowerCase(),
|
||||
parseAllowTarget,
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("matches chat IDs when provided", () => {
|
||||
const allowed = isAllowedParsedChatSender({
|
||||
allowFrom: ["chat_id:42"],
|
||||
sender: "+15551234567",
|
||||
chatId: 42,
|
||||
normalizeSender: (sender) => sender,
|
||||
parseAllowTarget,
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
it.each([
|
||||
{
|
||||
name: "denies when allowFrom is empty",
|
||||
input: {
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "allows wildcard entries",
|
||||
input: {
|
||||
allowFrom: ["*"],
|
||||
sender: "user@example.com",
|
||||
normalizeSender: (sender: string) => sender.toLowerCase(),
|
||||
parseAllowTarget,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "matches normalized handles",
|
||||
input: {
|
||||
allowFrom: ["User@Example.com"],
|
||||
sender: "user@example.com",
|
||||
normalizeSender: (sender: string) => sender.toLowerCase(),
|
||||
parseAllowTarget,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "matches chat IDs when provided",
|
||||
input: {
|
||||
allowFrom: ["chat_id:42"],
|
||||
sender: "+15551234567",
|
||||
chatId: 42,
|
||||
normalizeSender: (sender: string) => sender,
|
||||
parseAllowTarget,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(isAllowedParsedChatSender(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNormalizedSenderAllowed", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
isNormalizedSenderAllowed({
|
||||
it.each([
|
||||
{
|
||||
name: "allows wildcard",
|
||||
input: {
|
||||
senderId: "attacker",
|
||||
allowFrom: ["*"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes case and strips prefixes", () => {
|
||||
expect(
|
||||
isNormalizedSenderAllowed({
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "normalizes case and strips prefixes",
|
||||
input: {
|
||||
senderId: "12345",
|
||||
allowFrom: ["ZALO:12345", "zl:777"],
|
||||
stripPrefixRe: /^(zalo|zl):/i,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when sender is missing", () => {
|
||||
expect(
|
||||
isNormalizedSenderAllowed({
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "rejects when sender is missing",
|
||||
input: {
|
||||
senderId: "999",
|
||||
allowFrom: ["zl:12345"],
|
||||
stripPrefixRe: /^(zalo|zl):/i,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(isNormalizedSenderAllowed(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,21 +123,24 @@ describe("formatAllowFromLowercase", () => {
|
||||
});
|
||||
|
||||
describe("formatNormalizedAllowFromEntries", () => {
|
||||
it("applies custom normalization after trimming", () => {
|
||||
expect(
|
||||
formatNormalizedAllowFromEntries({
|
||||
it.each([
|
||||
{
|
||||
name: "applies custom normalization after trimming",
|
||||
input: {
|
||||
allowFrom: [" @Alice ", "", " @Bob "],
|
||||
normalizeEntry: (entry) => entry.replace(/^@/, "").toLowerCase(),
|
||||
}),
|
||||
).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("filters empty normalized entries", () => {
|
||||
expect(
|
||||
formatNormalizedAllowFromEntries({
|
||||
normalizeEntry: (entry: string) => entry.replace(/^@/, "").toLowerCase(),
|
||||
},
|
||||
expected: ["alice", "bob"],
|
||||
},
|
||||
{
|
||||
name: "filters empty normalized entries",
|
||||
input: {
|
||||
allowFrom: ["@", "valid"],
|
||||
normalizeEntry: (entry) => entry.replace(/^@$/, ""),
|
||||
}),
|
||||
).toEqual(["valid"]);
|
||||
normalizeEntry: (entry: string) => entry.replace(/^@$/, ""),
|
||||
},
|
||||
expected: ["valid"],
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(formatNormalizedAllowFromEntries(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,30 +101,28 @@ describe("createNestedAllowlistOverrideResolver", () => {
|
||||
});
|
||||
|
||||
describe("createAccountScopedAllowlistNameResolver", () => {
|
||||
it("returns empty results when the resolved account has no token", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "returns empty results when the resolved account has no token",
|
||||
token: "",
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: "delegates to the resolver when a token is present",
|
||||
token: " secret ",
|
||||
expected: [{ input: "a", resolved: true, name: "secret:a" }],
|
||||
},
|
||||
])("$name", async ({ token, expected }) => {
|
||||
const resolveNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: () => ({ token: "" }),
|
||||
resolveToken: (account) => account.token,
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
entries.map((entry) => ({ input: `${token}:${entry}`, resolved: true })),
|
||||
});
|
||||
|
||||
expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates to the resolver when a token is present", async () => {
|
||||
const resolveNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: () => ({ token: " secret " }),
|
||||
resolveAccount: () => ({ token }),
|
||||
resolveToken: (account) => account.token,
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
entries.map((entry) => ({ input: entry, resolved: true, name: `${token}:${entry}` })),
|
||||
});
|
||||
|
||||
expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual([
|
||||
{ input: "a", resolved: true, name: "secret:a" },
|
||||
]);
|
||||
expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,10 +145,14 @@ describe("buildDmGroupAccountAllowlistAdapter", () => {
|
||||
resolveGroupOverrides: (account) => account.groupOverrides,
|
||||
});
|
||||
|
||||
it("supports dm, group, and all scopes", () => {
|
||||
expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true);
|
||||
expect(adapter.supportsScope?.({ scope: "group" })).toBe(true);
|
||||
expect(adapter.supportsScope?.({ scope: "all" })).toBe(true);
|
||||
const scopeCases: Array<{ scope: "dm" | "group" | "all"; expected: boolean }> = [
|
||||
{ scope: "dm", expected: true },
|
||||
{ scope: "group", expected: true },
|
||||
{ scope: "all", expected: true },
|
||||
];
|
||||
|
||||
it.each(scopeCases)("supports $scope scope", ({ scope, expected }) => {
|
||||
expect(adapter.supportsScope?.({ scope })).toBe(expected);
|
||||
});
|
||||
|
||||
it("reads dm/group config from the resolved account", () => {
|
||||
@@ -200,10 +202,14 @@ describe("buildLegacyDmAccountAllowlistAdapter", () => {
|
||||
resolveGroupOverrides: (account) => account.groupOverrides,
|
||||
});
|
||||
|
||||
it("supports only dm scope", () => {
|
||||
expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true);
|
||||
expect(adapter.supportsScope?.({ scope: "group" })).toBe(false);
|
||||
expect(adapter.supportsScope?.({ scope: "all" })).toBe(false);
|
||||
const scopeCases: Array<{ scope: "dm" | "group" | "all"; expected: boolean }> = [
|
||||
{ scope: "dm", expected: true },
|
||||
{ scope: "group", expected: false },
|
||||
{ scope: "all", expected: false },
|
||||
];
|
||||
|
||||
it.each(scopeCases)("supports $scope scope", ({ scope, expected }) => {
|
||||
expect(adapter.supportsScope?.({ scope })).toBe(expected);
|
||||
});
|
||||
|
||||
it("reads legacy dm config from the resolved account", () => {
|
||||
|
||||
@@ -73,26 +73,43 @@ describe("createAttachedChannelResultAdapter", () => {
|
||||
sendPoll: async () => ({ messageId: "m3", pollId: "p1" }),
|
||||
});
|
||||
|
||||
await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
|
||||
channel: "discord",
|
||||
messageId: "m1",
|
||||
channelId: "c1",
|
||||
});
|
||||
await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
|
||||
channel: "discord",
|
||||
messageId: "m2",
|
||||
});
|
||||
await expect(
|
||||
adapter.sendPoll!({
|
||||
cfg: {} as never,
|
||||
to: "x",
|
||||
poll: { question: "t", options: ["a", "b"] },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
channel: "discord",
|
||||
messageId: "m3",
|
||||
pollId: "p1",
|
||||
});
|
||||
const sendCases = [
|
||||
{
|
||||
name: "sendText",
|
||||
run: () => adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" }),
|
||||
expected: {
|
||||
channel: "discord",
|
||||
messageId: "m1",
|
||||
channelId: "c1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sendMedia",
|
||||
run: () => adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" }),
|
||||
expected: {
|
||||
channel: "discord",
|
||||
messageId: "m2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sendPoll",
|
||||
run: () =>
|
||||
adapter.sendPoll!({
|
||||
cfg: {} as never,
|
||||
to: "x",
|
||||
poll: { question: "t", options: ["a", "b"] },
|
||||
}),
|
||||
expected: {
|
||||
channel: "discord",
|
||||
messageId: "m3",
|
||||
pollId: "p1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of sendCases) {
|
||||
await expect(testCase.run()).resolves.toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,17 +121,31 @@ describe("createRawChannelSendResultAdapter", () => {
|
||||
sendMedia: async () => ({ ok: false, error: "boom" }),
|
||||
});
|
||||
|
||||
await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
|
||||
channel: "zalo",
|
||||
ok: true,
|
||||
messageId: "m1",
|
||||
error: undefined,
|
||||
});
|
||||
await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({
|
||||
channel: "zalo",
|
||||
ok: false,
|
||||
messageId: "",
|
||||
error: new Error("boom"),
|
||||
});
|
||||
const sendCases = [
|
||||
{
|
||||
name: "sendText",
|
||||
run: () => adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" }),
|
||||
expected: {
|
||||
channel: "zalo",
|
||||
ok: true,
|
||||
messageId: "m1",
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sendMedia",
|
||||
run: () => adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" }),
|
||||
expected: {
|
||||
channel: "zalo",
|
||||
ok: false,
|
||||
messageId: "",
|
||||
error: new Error("boom"),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of sendCases) {
|
||||
await expect(testCase.run()).resolves.toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,256 +8,306 @@ import {
|
||||
} from "./group-access.js";
|
||||
|
||||
describe("resolveSenderScopedGroupPolicy", () => {
|
||||
it("preserves disabled policy", () => {
|
||||
expect(
|
||||
resolveSenderScopedGroupPolicy({
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: Parameters<typeof resolveSenderScopedGroupPolicy>[0];
|
||||
expected: ReturnType<typeof resolveSenderScopedGroupPolicy>;
|
||||
}> = [
|
||||
{
|
||||
name: "preserves disabled policy",
|
||||
input: {
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["a"],
|
||||
}),
|
||||
).toBe("disabled");
|
||||
});
|
||||
|
||||
it("maps open/allowlist based on effective sender allowlist", () => {
|
||||
expect(
|
||||
resolveSenderScopedGroupPolicy({
|
||||
},
|
||||
expected: "disabled",
|
||||
},
|
||||
{
|
||||
name: "keeps allowlist policy when sender allowlist is present",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["a"],
|
||||
}),
|
||||
).toBe("allowlist");
|
||||
expect(
|
||||
resolveSenderScopedGroupPolicy({
|
||||
},
|
||||
expected: "allowlist",
|
||||
},
|
||||
{
|
||||
name: "maps allowlist to open when sender allowlist is empty",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
}),
|
||||
).toBe("open");
|
||||
},
|
||||
expected: "open",
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", ({ input, expected }) => {
|
||||
expect(resolveSenderScopedGroupPolicy(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateSenderGroupAccessForPolicy", () => {
|
||||
it("blocks disabled policy", () => {
|
||||
const decision = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: Parameters<typeof evaluateSenderGroupAccessForPolicy>[0];
|
||||
expected: Partial<ReturnType<typeof evaluateSenderGroupAccessForPolicy>>;
|
||||
}> = [
|
||||
{
|
||||
name: "blocks disabled policy",
|
||||
input: {
|
||||
groupPolicy: "disabled",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
},
|
||||
expected: { allowed: false, reason: "disabled", groupPolicy: "disabled" },
|
||||
},
|
||||
{
|
||||
name: "blocks allowlist with empty list",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(decision).toMatchObject({ allowed: false, reason: "disabled", groupPolicy: "disabled" });
|
||||
});
|
||||
|
||||
it("blocks allowlist with empty list", () => {
|
||||
const decision = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
it.each(cases)("$name", ({ input, expected }) => {
|
||||
expect(evaluateSenderGroupAccessForPolicy(input)).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateGroupRouteAccessForPolicy", () => {
|
||||
it("blocks disabled policy", () => {
|
||||
expect(
|
||||
evaluateGroupRouteAccessForPolicy({
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: Parameters<typeof evaluateGroupRouteAccessForPolicy>[0];
|
||||
expected: ReturnType<typeof evaluateGroupRouteAccessForPolicy>;
|
||||
}> = [
|
||||
{
|
||||
name: "blocks disabled policy",
|
||||
input: {
|
||||
groupPolicy: "disabled",
|
||||
routeAllowlistConfigured: true,
|
||||
routeMatched: true,
|
||||
routeEnabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "disabled",
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks allowlist without configured routes", () => {
|
||||
expect(
|
||||
evaluateGroupRouteAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "disabled",
|
||||
reason: "disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks allowlist without configured routes",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
routeAllowlistConfigured: false,
|
||||
routeMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "empty_allowlist",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unmatched allowlist route", () => {
|
||||
expect(
|
||||
evaluateGroupRouteAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "empty_allowlist",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks unmatched allowlist route",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
routeAllowlistConfigured: true,
|
||||
routeMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "route_not_allowlisted",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks disabled matched route even when group policy is open", () => {
|
||||
expect(
|
||||
evaluateGroupRouteAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "route_not_allowlisted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks disabled matched route even when group policy is open",
|
||||
input: {
|
||||
groupPolicy: "open",
|
||||
routeAllowlistConfigured: true,
|
||||
routeMatched: true,
|
||||
routeEnabled: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "open",
|
||||
reason: "route_disabled",
|
||||
});
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "open",
|
||||
reason: "route_disabled",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", ({ input, expected }) => {
|
||||
expect(evaluateGroupRouteAccessForPolicy(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMatchedGroupAccessForPolicy", () => {
|
||||
it("blocks disabled policy", () => {
|
||||
expect(
|
||||
evaluateMatchedGroupAccessForPolicy({
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: Parameters<typeof evaluateMatchedGroupAccessForPolicy>[0];
|
||||
expected: ReturnType<typeof evaluateMatchedGroupAccessForPolicy>;
|
||||
}> = [
|
||||
{
|
||||
name: "blocks disabled policy",
|
||||
input: {
|
||||
groupPolicy: "disabled",
|
||||
allowlistConfigured: true,
|
||||
allowlistMatched: true,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "disabled",
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks allowlist without configured entries", () => {
|
||||
expect(
|
||||
evaluateMatchedGroupAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "disabled",
|
||||
reason: "disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks allowlist without configured entries",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
allowlistConfigured: false,
|
||||
allowlistMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "empty_allowlist",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks allowlist when required match input is missing", () => {
|
||||
expect(
|
||||
evaluateMatchedGroupAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "empty_allowlist",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks allowlist when required match input is missing",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
requireMatchInput: true,
|
||||
hasMatchInput: false,
|
||||
allowlistConfigured: true,
|
||||
allowlistMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "missing_match_input",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unmatched allowlist sender", () => {
|
||||
expect(
|
||||
evaluateMatchedGroupAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "missing_match_input",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blocks unmatched allowlist sender",
|
||||
input: {
|
||||
groupPolicy: "allowlist",
|
||||
allowlistConfigured: true,
|
||||
allowlistMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "not_allowlisted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows open policy", () => {
|
||||
expect(
|
||||
evaluateMatchedGroupAccessForPolicy({
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "not_allowlisted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allows open policy",
|
||||
input: {
|
||||
groupPolicy: "open",
|
||||
allowlistConfigured: false,
|
||||
allowlistMatched: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
groupPolicy: "open",
|
||||
reason: "allowed",
|
||||
});
|
||||
},
|
||||
expected: {
|
||||
allowed: true,
|
||||
groupPolicy: "open",
|
||||
reason: "allowed",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", ({ input, expected }) => {
|
||||
expect(evaluateMatchedGroupAccessForPolicy(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateSenderGroupAccess", () => {
|
||||
it("defaults missing provider config to allowlist", () => {
|
||||
const decision = evaluateSenderGroupAccess({
|
||||
providerConfigPresent: false,
|
||||
configuredGroupPolicy: undefined,
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: Parameters<typeof evaluateSenderGroupAccess>[0];
|
||||
expected: Partial<ReturnType<typeof evaluateSenderGroupAccess>>;
|
||||
matcher: "equal" | "match";
|
||||
}> = [
|
||||
{
|
||||
name: "defaults missing provider config to allowlist",
|
||||
input: {
|
||||
providerConfigPresent: false,
|
||||
configuredGroupPolicy: undefined,
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
},
|
||||
expected: {
|
||||
allowed: true,
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: true,
|
||||
reason: "allowed",
|
||||
},
|
||||
matcher: "equal",
|
||||
},
|
||||
{
|
||||
name: "blocks disabled policy",
|
||||
input: {
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "disabled",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
},
|
||||
expected: { allowed: false, reason: "disabled", groupPolicy: "disabled" },
|
||||
matcher: "match",
|
||||
},
|
||||
{
|
||||
name: "blocks allowlist with empty list",
|
||||
input: {
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "allowlist",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
matcher: "match",
|
||||
},
|
||||
{
|
||||
name: "blocks sender not allowlisted",
|
||||
input: {
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "allowlist",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "999",
|
||||
isSenderAllowed: () => false,
|
||||
},
|
||||
expected: {
|
||||
allowed: false,
|
||||
reason: "sender_not_allowlisted",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
matcher: "match",
|
||||
},
|
||||
];
|
||||
|
||||
expect(decision).toEqual({
|
||||
allowed: true,
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: true,
|
||||
reason: "allowed",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks disabled policy", () => {
|
||||
const decision = evaluateSenderGroupAccess({
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "disabled",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({ allowed: false, reason: "disabled", groupPolicy: "disabled" });
|
||||
});
|
||||
|
||||
it("blocks allowlist with empty list", () => {
|
||||
const decision = evaluateSenderGroupAccess({
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "allowlist",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
senderId: "123",
|
||||
isSenderAllowed: () => true,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks sender not allowlisted", () => {
|
||||
const decision = evaluateSenderGroupAccess({
|
||||
providerConfigPresent: true,
|
||||
configuredGroupPolicy: "allowlist",
|
||||
defaultGroupPolicy: "open",
|
||||
groupAllowFrom: ["123"],
|
||||
senderId: "999",
|
||||
isSenderAllowed: () => false,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "sender_not_allowlisted",
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
it.each(cases)("$name", ({ input, expected, matcher }) => {
|
||||
const decision = evaluateSenderGroupAccess(input);
|
||||
if (matcher === "equal") {
|
||||
expect(decision).toEqual(expected);
|
||||
return;
|
||||
}
|
||||
expect(decision).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,119 +18,167 @@ function createLookupFn(addresses: Array<{ address: string; family: number }>):
|
||||
}
|
||||
|
||||
describe("ssrfPolicyFromAllowPrivateNetwork", () => {
|
||||
it("returns undefined unless private-network access is explicitly enabled", () => {
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(undefined)).toBeUndefined();
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(false)).toBeUndefined();
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(true)).toEqual({ allowPrivateNetwork: true });
|
||||
it.each([
|
||||
{
|
||||
name: "returns undefined for missing input",
|
||||
input: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined when private-network access is disabled",
|
||||
input: false,
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns an explicit allow-private-network policy when enabled",
|
||||
input: true,
|
||||
expected: { allowPrivateNetwork: true },
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(ssrfPolicyFromAllowPrivateNetwork(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertHttpUrlTargetsPrivateNetwork", () => {
|
||||
it("allows https targets without private-network checks", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("https://matrix.example.org", {
|
||||
it.each([
|
||||
{
|
||||
name: "allows https targets without private-network checks",
|
||||
url: "https://matrix.example.org",
|
||||
policy: {
|
||||
allowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows internal DNS names only when they resolve exclusively to private IPs", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("http://matrix-synapse:8008", {
|
||||
},
|
||||
outcome: "resolve",
|
||||
},
|
||||
{
|
||||
name: "allows internal DNS names only when they resolve exclusively to private IPs",
|
||||
url: "http://matrix-synapse:8008",
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "10.0.0.5", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects cleartext public hosts even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
assertHttpUrlTargetsPrivateNetwork("http://matrix.example.org:8008", {
|
||||
},
|
||||
outcome: "resolve",
|
||||
},
|
||||
{
|
||||
name: "rejects cleartext public hosts even when private-network access is enabled",
|
||||
url: "http://matrix.example.org:8008",
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
errorMessage:
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
},
|
||||
outcome: "reject",
|
||||
expectedError:
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
},
|
||||
])("$name", async ({ url, policy, outcome, expectedError }) => {
|
||||
const result = assertHttpUrlTargetsPrivateNetwork(url, policy);
|
||||
if (outcome === "reject") {
|
||||
await expect(result).rejects.toThrow(expectedError);
|
||||
return;
|
||||
}
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostnameSuffixAllowlist", () => {
|
||||
it("uses defaults when input is missing", () => {
|
||||
expect(normalizeHostnameSuffixAllowlist(undefined, ["GRAPH.MICROSOFT.COM"])).toEqual([
|
||||
"graph.microsoft.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes wildcard prefixes and deduplicates", () => {
|
||||
expect(
|
||||
normalizeHostnameSuffixAllowlist([
|
||||
"*.TrafficManager.NET",
|
||||
".trafficmanager.net.",
|
||||
" * ",
|
||||
"x",
|
||||
]),
|
||||
).toEqual(["*"]);
|
||||
it.each([
|
||||
{
|
||||
name: "uses defaults when input is missing",
|
||||
input: undefined,
|
||||
defaults: ["GRAPH.MICROSOFT.COM"],
|
||||
expected: ["graph.microsoft.com"],
|
||||
},
|
||||
{
|
||||
name: "normalizes wildcard prefixes and deduplicates",
|
||||
input: ["*.TrafficManager.NET", ".trafficmanager.net.", " * ", "x"],
|
||||
defaults: undefined,
|
||||
expected: ["*"],
|
||||
},
|
||||
])("$name", ({ input, defaults, expected }) => {
|
||||
expect(normalizeHostnameSuffixAllowlist(input, defaults)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHttpsUrlAllowedByHostnameSuffixAllowlist", () => {
|
||||
it("requires https", () => {
|
||||
expect(
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist("http://a.example.com/x", ["example.com"]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("supports exact and suffix match", () => {
|
||||
expect(
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist("https://example.com/x", ["example.com"]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist("https://a.example.com/x", ["example.com"]),
|
||||
).toBe(true);
|
||||
expect(isHttpsUrlAllowedByHostnameSuffixAllowlist("https://evil.com/x", ["example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("supports wildcard allowlist", () => {
|
||||
expect(isHttpsUrlAllowedByHostnameSuffixAllowlist("https://evil.com/x", ["*"])).toBe(true);
|
||||
it.each([
|
||||
{
|
||||
name: "requires https",
|
||||
url: "http://a.example.com/x",
|
||||
allowlist: ["example.com"],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "supports exact match",
|
||||
url: "https://example.com/x",
|
||||
allowlist: ["example.com"],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "supports suffix match",
|
||||
url: "https://a.example.com/x",
|
||||
allowlist: ["example.com"],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "rejects non-matching hosts",
|
||||
url: "https://evil.com/x",
|
||||
allowlist: ["example.com"],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "supports wildcard allowlist",
|
||||
url: "https://evil.com/x",
|
||||
allowlist: ["*"],
|
||||
expected: true,
|
||||
},
|
||||
])("$name", ({ url, allowlist, expected }) => {
|
||||
expect(isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHostnameAllowlistPolicyFromSuffixAllowlist", () => {
|
||||
it("returns undefined when allowHosts is empty", () => {
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist()).toBeUndefined();
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when wildcard host is present", () => {
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist(["*"])).toBeUndefined();
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist(["example.com", "*"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands a suffix entry to exact + wildcard hostname allowlist patterns", () => {
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist(["sharepoint.com"])).toEqual({
|
||||
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes wildcard prefixes, leading/trailing dots, and deduplicates patterns", () => {
|
||||
expect(
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist([
|
||||
"*.TrafficManager.NET",
|
||||
".trafficmanager.net.",
|
||||
" blob.core.windows.net ",
|
||||
]),
|
||||
).toEqual({
|
||||
hostnameAllowlist: [
|
||||
"trafficmanager.net",
|
||||
"*.trafficmanager.net",
|
||||
"blob.core.windows.net",
|
||||
"*.blob.core.windows.net",
|
||||
],
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "returns undefined when allowHosts is empty",
|
||||
input: undefined,
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined for an explicit empty list",
|
||||
input: [],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined when wildcard host is present",
|
||||
input: ["*"],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "returns undefined when wildcard is mixed with concrete hosts",
|
||||
input: ["example.com", "*"],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "expands a suffix entry to exact + wildcard hostname allowlist patterns",
|
||||
input: ["sharepoint.com"],
|
||||
expected: {
|
||||
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "normalizes wildcard prefixes, leading/trailing dots, and deduplicates patterns",
|
||||
input: ["*.TrafficManager.NET", ".trafficmanager.net.", " blob.core.windows.net "],
|
||||
expected: {
|
||||
hostnameAllowlist: [
|
||||
"trafficmanager.net",
|
||||
"*.trafficmanager.net",
|
||||
"blob.core.windows.net",
|
||||
"*.blob.core.windows.net",
|
||||
],
|
||||
},
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,16 +21,20 @@ describe("createFixedWindowRateLimiter", () => {
|
||||
expect(limiter.isRateLimited("k", 1_003)).toBe(true);
|
||||
});
|
||||
|
||||
it("resets counters after the window elapses", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "resets counters after the window elapses",
|
||||
calls: [100, 101, 111],
|
||||
expected: [false, true, false],
|
||||
},
|
||||
])("$name", ({ calls, expected }) => {
|
||||
const limiter = createFixedWindowRateLimiter({
|
||||
windowMs: 10,
|
||||
maxRequests: 1,
|
||||
maxTrackedKeys: 100,
|
||||
});
|
||||
|
||||
expect(limiter.isRateLimited("k", 100)).toBe(false);
|
||||
expect(limiter.isRateLimited("k", 101)).toBe(true);
|
||||
expect(limiter.isRateLimited("k", 111)).toBe(false);
|
||||
expect(calls.map((nowMs) => limiter.isRateLimited("k", nowMs))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("caps tracked keys", () => {
|
||||
@@ -69,9 +73,7 @@ describe("createBoundedCounter", () => {
|
||||
it("increments and returns per-key counts", () => {
|
||||
const counter = createBoundedCounter({ maxTrackedKeys: 100 });
|
||||
|
||||
expect(counter.increment("k", 1_000)).toBe(1);
|
||||
expect(counter.increment("k", 1_001)).toBe(2);
|
||||
expect(counter.increment("k", 1_002)).toBe(3);
|
||||
expect([1_000, 1_001, 1_002].map((nowMs) => counter.increment("k", nowMs))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("caps tracked keys", () => {
|
||||
@@ -121,33 +123,29 @@ describe("createWebhookAnomalyTracker", () => {
|
||||
logEvery: 2,
|
||||
});
|
||||
|
||||
expect(
|
||||
tracker.record({
|
||||
key: "k",
|
||||
const counts = [
|
||||
{
|
||||
statusCode: 415,
|
||||
message: (count) => `ignored:${count}`,
|
||||
log: (msg) => logs.push(msg),
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
message: (count: number) => `ignored:${count}`,
|
||||
},
|
||||
{
|
||||
statusCode: 401,
|
||||
message: (count: number) => `hit:${count}`,
|
||||
},
|
||||
{
|
||||
statusCode: 401,
|
||||
message: (count: number) => `hit:${count}`,
|
||||
},
|
||||
].map(({ statusCode, message }) =>
|
||||
tracker.record({
|
||||
key: "k",
|
||||
statusCode: 401,
|
||||
message: (count) => `hit:${count}`,
|
||||
statusCode,
|
||||
message,
|
||||
log: (msg) => logs.push(msg),
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
expect(
|
||||
tracker.record({
|
||||
key: "k",
|
||||
statusCode: 401,
|
||||
message: (count) => `hit:${count}`,
|
||||
log: (msg) => logs.push(msg),
|
||||
}),
|
||||
).toBe(2);
|
||||
);
|
||||
|
||||
expect(counts).toEqual([0, 1, 2]);
|
||||
expect(logs).toEqual(["hit:1", "hit:2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,15 +46,45 @@ function createMockRequest(params: {
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("isJsonContentType", () => {
|
||||
it("accepts application/json and +json suffixes", () => {
|
||||
expect(isJsonContentType("application/json")).toBe(true);
|
||||
expect(isJsonContentType("application/cloudevents+json; charset=utf-8")).toBe(true);
|
||||
});
|
||||
async function readJsonBody(chunks: string[], emptyObjectOnEmpty = false) {
|
||||
const req = createMockRequest({ chunks });
|
||||
const res = createMockServerResponse();
|
||||
return {
|
||||
result: await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 1024,
|
||||
emptyObjectOnEmpty,
|
||||
}),
|
||||
res,
|
||||
};
|
||||
}
|
||||
|
||||
it("rejects non-json media types", () => {
|
||||
expect(isJsonContentType("text/plain")).toBe(false);
|
||||
expect(isJsonContentType(undefined)).toBe(false);
|
||||
async function readRawBody(params: Parameters<typeof createMockRequest>[0], profile?: "pre-auth") {
|
||||
const req = createMockRequest(params);
|
||||
const res = createMockServerResponse();
|
||||
return {
|
||||
result: await readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile,
|
||||
}),
|
||||
res,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isJsonContentType", () => {
|
||||
it.each([
|
||||
{ name: "accepts application/json", input: "application/json", expected: true },
|
||||
{
|
||||
name: "accepts +json suffixes",
|
||||
input: "application/cloudevents+json; charset=utf-8",
|
||||
expected: true,
|
||||
},
|
||||
{ name: "rejects non-json media types", input: "text/plain", expected: false },
|
||||
{ name: "rejects missing media types", input: undefined, expected: false },
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(isJsonContentType(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,89 +133,82 @@ describe("applyBasicWebhookRequestGuards", () => {
|
||||
expect(res2.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it("rejects non-json requests when required", () => {
|
||||
const req = createMockRequest({
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "allows matching JSON requests",
|
||||
req: createMockRequest({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
expectedOk: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "rejects non-json requests when required",
|
||||
req: createMockRequest({
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
expectedOk: false,
|
||||
expectedStatusCode: 415,
|
||||
},
|
||||
])("$name", ({ req, expectedOk, expectedStatusCode }) => {
|
||||
const res = createMockServerResponse();
|
||||
const ok = applyBasicWebhookRequestGuards({
|
||||
req,
|
||||
res,
|
||||
requireJsonContentType: true,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(ok).toBe(expectedOk);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readJsonWebhookBodyOrReject", () => {
|
||||
it("returns parsed JSON body", async () => {
|
||||
const req = createMockRequest({ chunks: ['{"ok":true}'] });
|
||||
const res = createMockServerResponse();
|
||||
await expect(
|
||||
readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 1024,
|
||||
emptyObjectOnEmpty: false,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: { ok: true } });
|
||||
});
|
||||
|
||||
it("preserves valid JSON null payload", async () => {
|
||||
const req = createMockRequest({ chunks: ["null"] });
|
||||
const res = createMockServerResponse();
|
||||
await expect(
|
||||
readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 1024,
|
||||
emptyObjectOnEmpty: false,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("writes 400 on invalid JSON payload", async () => {
|
||||
const req = createMockRequest({ chunks: ["{bad json"] });
|
||||
const res = createMockServerResponse();
|
||||
await expect(
|
||||
readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 1024,
|
||||
emptyObjectOnEmpty: false,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toBe("Bad Request");
|
||||
it.each([
|
||||
{
|
||||
name: "returns parsed JSON body",
|
||||
chunks: ['{"ok":true}'],
|
||||
expected: { ok: true, value: { ok: true } },
|
||||
expectedStatusCode: 200,
|
||||
expectedBody: undefined,
|
||||
},
|
||||
{
|
||||
name: "preserves valid JSON null payload",
|
||||
chunks: ["null"],
|
||||
expected: { ok: true, value: null },
|
||||
expectedStatusCode: 200,
|
||||
expectedBody: undefined,
|
||||
},
|
||||
{
|
||||
name: "writes 400 on invalid JSON payload",
|
||||
chunks: ["{bad json"],
|
||||
expected: { ok: false },
|
||||
expectedStatusCode: 400,
|
||||
expectedBody: "Bad Request",
|
||||
},
|
||||
])("$name", async ({ chunks, expected, expectedStatusCode, expectedBody }) => {
|
||||
const { result, res } = await readJsonBody(chunks);
|
||||
expect(result).toEqual(expected);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
expect(res.body).toBe(expectedBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWebhookBodyOrReject", () => {
|
||||
it("returns raw body contents", async () => {
|
||||
const req = createMockRequest({ chunks: ["plain text"] });
|
||||
const res = createMockServerResponse();
|
||||
await expect(
|
||||
readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: "plain text" });
|
||||
const { result } = await readRawBody({ chunks: ["plain text"] });
|
||||
expect(result).toEqual({ ok: true, value: "plain text" });
|
||||
});
|
||||
|
||||
it("enforces strict pre-auth default body limits", async () => {
|
||||
const req = createMockRequest({
|
||||
headers: { "content-length": String(70 * 1024) },
|
||||
});
|
||||
const res = createMockServerResponse();
|
||||
await expect(
|
||||
readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
profile: "pre-auth",
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
const { result, res } = await readRawBody(
|
||||
{
|
||||
headers: { "content-length": String(70 * 1024) },
|
||||
},
|
||||
"pre-auth",
|
||||
);
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,28 @@ function createRequest(method: string, url: string): IncomingMessage {
|
||||
return req;
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const setHeader = vi.fn();
|
||||
const end = vi.fn();
|
||||
return {
|
||||
res: {
|
||||
statusCode: 200,
|
||||
setHeader,
|
||||
end,
|
||||
} as unknown as ServerResponse,
|
||||
setHeader,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
function createPipelineRequest(url: string): IncomingMessage {
|
||||
const req = createRequest("POST", url);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
return req;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
@@ -141,31 +163,31 @@ describe("registerWebhookTargetWithPluginRoute", () => {
|
||||
});
|
||||
|
||||
describe("resolveWebhookTargets", () => {
|
||||
it("resolves normalized path targets", () => {
|
||||
const targets = new Map<string, Array<{ id: string }>>();
|
||||
targets.set("/hook", [{ id: "A" }]);
|
||||
|
||||
expect(resolveWebhookTargets(createRequest("POST", "/hook/"), targets)).toEqual({
|
||||
path: "/hook",
|
||||
targets: [{ id: "A" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when path has no targets", () => {
|
||||
const targets = new Map<string, Array<{ id: string }>>();
|
||||
expect(resolveWebhookTargets(createRequest("POST", "/missing"), targets)).toBeNull();
|
||||
it.each([
|
||||
{
|
||||
name: "resolves normalized path targets",
|
||||
requestPath: "/hook/",
|
||||
targets: new Map([["/hook", [{ id: "A" }]]]),
|
||||
expected: {
|
||||
path: "/hook",
|
||||
targets: [{ id: "A" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns null when path has no targets",
|
||||
requestPath: "/missing",
|
||||
targets: new Map<string, Array<{ id: string }>>(),
|
||||
expected: null,
|
||||
},
|
||||
])("$name", ({ requestPath, targets, expected }) => {
|
||||
expect(resolveWebhookTargets(createRequest("POST", requestPath), targets)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withResolvedWebhookRequestPipeline", () => {
|
||||
it("returns false when request path has no registered targets", async () => {
|
||||
const req = createRequest("POST", "/missing");
|
||||
req.headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
const { res } = createResponse();
|
||||
const handled = await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
res,
|
||||
@@ -177,16 +199,8 @@ describe("withResolvedWebhookRequestPipeline", () => {
|
||||
});
|
||||
|
||||
it("runs handler when targets resolve and method passes", async () => {
|
||||
const req = createRequest("POST", "/hook");
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
const req = createPipelineRequest("/hook");
|
||||
const { res } = createResponse();
|
||||
const handle = vi.fn(async () => {});
|
||||
const handled = await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
@@ -200,16 +214,8 @@ describe("withResolvedWebhookRequestPipeline", () => {
|
||||
});
|
||||
|
||||
it("releases in-flight slot when handler throws", async () => {
|
||||
const req = createRequest("POST", "/hook");
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
const req = createPipelineRequest("/hook");
|
||||
const { res } = createResponse();
|
||||
const limiter = createWebhookInFlightLimiter();
|
||||
|
||||
await expect(
|
||||
@@ -231,20 +237,14 @@ describe("withResolvedWebhookRequestPipeline", () => {
|
||||
|
||||
describe("rejectNonPostWebhookRequest", () => {
|
||||
it("sets 405 for non-POST requests", () => {
|
||||
const setHeaderMock = vi.fn();
|
||||
const endMock = vi.fn();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: setHeaderMock,
|
||||
end: endMock,
|
||||
} as unknown as ServerResponse;
|
||||
const { res, setHeader, end } = createResponse();
|
||||
|
||||
const rejected = rejectNonPostWebhookRequest(createRequest("GET", "/hook"), res);
|
||||
|
||||
expect(rejected).toBe(true);
|
||||
expect(res.statusCode).toBe(405);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith("Allow", "POST");
|
||||
expect(endMock).toHaveBeenCalledWith("Method Not Allowed");
|
||||
expect(setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||
expect(end).toHaveBeenCalledWith("Method Not Allowed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,11 +291,7 @@ describe("resolveSingleWebhookTarget", () => {
|
||||
|
||||
describe("resolveWebhookTargetWithAuthOrReject", () => {
|
||||
it("returns matched target", async () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
const { res } = createResponse();
|
||||
await expect(
|
||||
resolveWebhookTargetWithAuthOrReject({
|
||||
targets: [{ id: "a" }, { id: "b" }],
|
||||
@@ -305,50 +301,36 @@ describe("resolveWebhookTargetWithAuthOrReject", () => {
|
||||
).resolves.toEqual({ id: "b" });
|
||||
});
|
||||
|
||||
it("writes unauthorized response on no match", async () => {
|
||||
const endMock = vi.fn();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: endMock,
|
||||
} as unknown as ServerResponse;
|
||||
it.each([
|
||||
{
|
||||
name: "writes unauthorized response on no match",
|
||||
targets: [{ id: "a" }],
|
||||
isMatch: () => false,
|
||||
expectedEnd: "unauthorized",
|
||||
},
|
||||
{
|
||||
name: "writes ambiguous response on multi-match",
|
||||
targets: [{ id: "a" }, { id: "b" }],
|
||||
isMatch: () => true,
|
||||
expectedEnd: "ambiguous webhook target",
|
||||
},
|
||||
])("$name", async ({ targets, isMatch, expectedEnd }) => {
|
||||
const { res, end } = createResponse();
|
||||
await expect(
|
||||
resolveWebhookTargetWithAuthOrReject({
|
||||
targets: [{ id: "a" }],
|
||||
targets,
|
||||
res,
|
||||
isMatch: () => false,
|
||||
isMatch,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(endMock).toHaveBeenCalledWith("unauthorized");
|
||||
});
|
||||
|
||||
it("writes ambiguous response on multi-match", async () => {
|
||||
const endMock = vi.fn();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: endMock,
|
||||
} as unknown as ServerResponse;
|
||||
await expect(
|
||||
resolveWebhookTargetWithAuthOrReject({
|
||||
targets: [{ id: "a" }, { id: "b" }],
|
||||
res,
|
||||
isMatch: () => true,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(endMock).toHaveBeenCalledWith("ambiguous webhook target");
|
||||
expect(end).toHaveBeenCalledWith(expectedEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWebhookTargetWithAuthOrRejectSync", () => {
|
||||
it("returns matched target synchronously", () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
const { res } = createResponse();
|
||||
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
||||
targets: [{ id: "a" }, { id: "b" }],
|
||||
res,
|
||||
|
||||
Reference in New Issue
Block a user