mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
test: collapse nextcloud-talk helper suites
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextcloudTalkPlugin } from "./channel.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("nextcloudTalkPlugin security", () => {
|
||||
it("normalizes trimmed dm allowlist prefixes to lowercase ids", () => {
|
||||
const resolveDmPolicy = nextcloudTalkPlugin.security?.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("resolveDmPolicy unavailable");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" nc:User-Id "],
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const result = resolveDmPolicy({
|
||||
cfg,
|
||||
account: nextcloudTalkPlugin.config.resolveAccount(cfg, "default"),
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("nextcloud-talk resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([" nc:User-Id "]);
|
||||
expect(result.normalizeEntry?.(" nc:User-Id ")).toBe("user-id");
|
||||
expect(nextcloudTalkPlugin.pairing?.normalizeAllowEntry?.(" nextcloud-talk:User-Id ")).toBe(
|
||||
"user-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nextcloudTalkPlugin } from "./channel.js";
|
||||
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
escapeNextcloudTalkMarkdown,
|
||||
@@ -9,7 +13,37 @@ import {
|
||||
stripNextcloudTalkFormatting,
|
||||
truncateNextcloudTalkText,
|
||||
} from "./format.js";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
stripNextcloudTalkTargetPrefix,
|
||||
} from "./normalize.js";
|
||||
import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
|
||||
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
|
||||
import {
|
||||
extractNextcloudTalkHeaders,
|
||||
generateNextcloudTalkSignature,
|
||||
verifyNextcloudTalkSignature,
|
||||
} from "./signature.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("nextcloud talk core", () => {
|
||||
it("accepts SecretRef botSecret and apiPassword at top-level", () => {
|
||||
@@ -96,4 +130,257 @@ describe("nextcloud talk core", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes and recognizes supported room target formats", () => {
|
||||
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
|
||||
|
||||
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
|
||||
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
|
||||
|
||||
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies generated signatures and extracts normalized headers", () => {
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const generated = generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "secret-123",
|
||||
});
|
||||
|
||||
expect(generated.random).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(generated.signature).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: generated.signature,
|
||||
random: generated.random,
|
||||
body,
|
||||
secret: "secret-123",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: "",
|
||||
random: "abc",
|
||||
body: "body",
|
||||
secret: "secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: "deadbeef",
|
||||
random: "abc",
|
||||
body: "body",
|
||||
secret: "secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
extractNextcloudTalkHeaders({
|
||||
"x-nextcloud-talk-signature": "sig",
|
||||
"x-nextcloud-talk-random": "rand",
|
||||
"x-nextcloud-talk-backend": "backend",
|
||||
}),
|
||||
).toEqual({
|
||||
signature: "sig",
|
||||
random: "rand",
|
||||
backend: "backend",
|
||||
});
|
||||
expect(
|
||||
extractNextcloudTalkHeaders({
|
||||
"X-Nextcloud-Talk-Signature": "sig",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("persists replay decisions across guard instances", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
|
||||
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const firstAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
const replayAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(firstAttempt).toBe(true);
|
||||
expect(replayAttempt).toBe(false);
|
||||
expect(restartReplayAttempt).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes replay state by account namespace", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
|
||||
const accountAFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
const accountBFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-b",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
|
||||
expect(accountAFirst).toBe(true);
|
||||
expect(accountBFirst).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes trimmed DM allowlist prefixes to lowercase ids", () => {
|
||||
const resolveDmPolicy = nextcloudTalkPlugin.security?.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("resolveDmPolicy unavailable");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" nc:User-Id "],
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const result = resolveDmPolicy({
|
||||
cfg,
|
||||
account: nextcloudTalkPlugin.config.resolveAccount(cfg, "default"),
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("nextcloud-talk resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([" nc:User-Id "]);
|
||||
expect(result.normalizeEntry?.(" nc:User-Id ")).toBe("user-id");
|
||||
expect(nextcloudTalkPlugin.pairing?.normalizeAllowEntry?.(" nextcloud-talk:User-Id ")).toBe(
|
||||
"user-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves allowlist matches and group policy decisions", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "user-id",
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["nc:User-Id"],
|
||||
senderId: "user-id",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["allowed"],
|
||||
senderId: "other",
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "disabled",
|
||||
outerAllowFrom: ["owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "open",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: true },
|
||||
innerMatch: { allowed: true },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "room-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["team-owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "room-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["team-owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "team-owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["shared-user"],
|
||||
innerAllowFrom: ["shared-user"],
|
||||
senderId: "shared-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
||||
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
|
||||
describe("createNextcloudTalkWebhookServer auth order", () => {
|
||||
it("rejects missing signature headers before reading request body", async () => {
|
||||
const readBody = vi.fn(async () => {
|
||||
throw new Error("should not be called for missing signature headers");
|
||||
});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-order",
|
||||
maxBodyBytes: 128,
|
||||
readBody,
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
||||
expect(readBody).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
|
||||
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
||||
it("rejects requests from unexpected backend origins", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-backend-check",
|
||||
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
||||
onMessage,
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedCreateMessageRequest({
|
||||
backend: "https://nextcloud.unexpected",
|
||||
});
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import { readNextcloudTalkWebhookBody } from "./monitor.js";
|
||||
|
||||
describe("readNextcloudTalkWebhookBody", () => {
|
||||
it("reads valid body within max bytes", async () => {
|
||||
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
||||
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
||||
expect(body).toBe('{"type":"Create"}');
|
||||
});
|
||||
|
||||
it("rejects when payload exceeds max bytes", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(300)]);
|
||||
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import { readNextcloudTalkWebhookBody } from "./monitor.js";
|
||||
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
describe("readNextcloudTalkWebhookBody", () => {
|
||||
it("reads valid body within max bytes", async () => {
|
||||
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
||||
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
||||
expect(body).toBe('{"type":"Create"}');
|
||||
});
|
||||
|
||||
it("rejects when payload exceeds max bytes", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(300)]);
|
||||
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNextcloudTalkWebhookServer auth order", () => {
|
||||
it("rejects missing signature headers before reading request body", async () => {
|
||||
const readBody = vi.fn(async () => {
|
||||
throw new Error("should not be called for missing signature headers");
|
||||
});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-order",
|
||||
maxBodyBytes: 128,
|
||||
readBody,
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
||||
expect(readBody).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
||||
it("rejects requests from unexpected backend origins", async () => {
|
||||
const onMessage = vi.fn(async () => {});
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-backend-check",
|
||||
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
||||
onMessage,
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedCreateMessageRequest({
|
||||
backend: "https://nextcloud.unexpected",
|
||||
});
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNextcloudTalkWebhookServer replay handling", () => {
|
||||
it("acknowledges replayed requests and skips onMessage side effects", async () => {
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
stripNextcloudTalkTargetPrefix,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("nextcloud-talk target normalization", () => {
|
||||
it("strips supported prefixes to a room token", () => {
|
||||
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes messaging targets to lowercase channel ids", () => {
|
||||
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
|
||||
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
|
||||
});
|
||||
|
||||
it("detects prefixed and bare room ids", () => {
|
||||
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
|
||||
|
||||
describe("nextcloud-talk policy", () => {
|
||||
describe("resolveNextcloudTalkAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "user-id",
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows sender id match with normalization", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["nc:User-Id"],
|
||||
senderId: "user-id",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("blocks when sender id does not match", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: ["allowed"],
|
||||
senderId: "other",
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextcloudTalkGroupAllow", () => {
|
||||
it("blocks disabled policy", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "disabled",
|
||||
outerAllowFrom: ["owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows open policy", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "open",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: true },
|
||||
innerMatch: { allowed: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks allowlist mode when both outer and inner allowlists are empty", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
senderId: "owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("requires inner match when only room-specific allowlist is configured", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "room-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks when outer allowlist misses even if inner allowlist matches", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["team-owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "room-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: false },
|
||||
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows when both outer and inner allowlists match", () => {
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["team-owner"],
|
||||
innerAllowFrom: ["room-user"],
|
||||
senderId: "team-owner",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
|
||||
innerMatch: { allowed: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveNextcloudTalkGroupAllow({
|
||||
groupPolicy: "allowlist",
|
||||
outerAllowFrom: ["shared-user"],
|
||||
innerAllowFrom: ["shared-user"],
|
||||
senderId: "shared-user",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: true,
|
||||
outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
||||
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("createNextcloudTalkReplayGuard", () => {
|
||||
it("persists replay decisions across guard instances", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
|
||||
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const firstAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
const replayAttempt = await firstGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(firstAttempt).toBe(true);
|
||||
expect(replayAttempt).toBe(false);
|
||||
expect(restartReplayAttempt).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes replay state by account namespace", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
|
||||
const accountAFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
const accountBFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-b",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
|
||||
expect(accountAFirst).toBe(true);
|
||||
expect(accountBFirst).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractNextcloudTalkHeaders,
|
||||
generateNextcloudTalkSignature,
|
||||
verifyNextcloudTalkSignature,
|
||||
} from "./signature.js";
|
||||
|
||||
describe("nextcloud talk signature helpers", () => {
|
||||
it("verifies generated signatures against the same body and secret", () => {
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const generated = generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "secret-123",
|
||||
});
|
||||
|
||||
expect(generated.random).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(generated.signature).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: generated.signature,
|
||||
random: generated.random,
|
||||
body,
|
||||
secret: "secret-123",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing fields and mismatched signatures", () => {
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: "",
|
||||
random: "abc",
|
||||
body: "body",
|
||||
secret: "secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: "deadbeef",
|
||||
random: "abc",
|
||||
body: "body",
|
||||
secret: "secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts normalized webhook headers", () => {
|
||||
expect(
|
||||
extractNextcloudTalkHeaders({
|
||||
"x-nextcloud-talk-signature": "sig",
|
||||
"x-nextcloud-talk-random": "rand",
|
||||
"x-nextcloud-talk-backend": "backend",
|
||||
}),
|
||||
).toEqual({
|
||||
signature: "sig",
|
||||
random: "rand",
|
||||
backend: "backend",
|
||||
});
|
||||
|
||||
expect(
|
||||
extractNextcloudTalkHeaders({
|
||||
"X-Nextcloud-Talk-Signature": "sig",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user