diff --git a/extensions/nextcloud-talk/src/channel.security.test.ts b/extensions/nextcloud-talk/src/channel.security.test.ts deleted file mode 100644 index bd787d256d5..00000000000 --- a/extensions/nextcloud-talk/src/channel.security.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index fe03aaffe0f..ef67ebe421f 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -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 { + 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" }, + }); + }); }); diff --git a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts b/extensions/nextcloud-talk/src/monitor.auth-order.test.ts deleted file mode 100644 index 6cc149dde47..00000000000 --- a/extensions/nextcloud-talk/src/monitor.auth-order.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/extensions/nextcloud-talk/src/monitor.backend.test.ts b/extensions/nextcloud-talk/src/monitor.backend.test.ts deleted file mode 100644 index 37fdbfcbab7..00000000000 --- a/extensions/nextcloud-talk/src/monitor.backend.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/extensions/nextcloud-talk/src/monitor.read-body.test.ts b/extensions/nextcloud-talk/src/monitor.read-body.test.ts deleted file mode 100644 index 950ea73f2d9..00000000000 --- a/extensions/nextcloud-talk/src/monitor.read-body.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 4cb2abeecd9..b0ecff8286f 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -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(); diff --git a/extensions/nextcloud-talk/src/normalize.test.ts b/extensions/nextcloud-talk/src/normalize.test.ts deleted file mode 100644 index 2419e063ff1..00000000000 --- a/extensions/nextcloud-talk/src/normalize.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/extensions/nextcloud-talk/src/policy.test.ts b/extensions/nextcloud-talk/src/policy.test.ts deleted file mode 100644 index 383a627fc31..00000000000 --- a/extensions/nextcloud-talk/src/policy.test.ts +++ /dev/null @@ -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" }, - }); - }); - }); -}); diff --git a/extensions/nextcloud-talk/src/replay-guard.test.ts b/extensions/nextcloud-talk/src/replay-guard.test.ts deleted file mode 100644 index 0bf18acb600..00000000000 --- a/extensions/nextcloud-talk/src/replay-guard.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/extensions/nextcloud-talk/src/signature.test.ts b/extensions/nextcloud-talk/src/signature.test.ts deleted file mode 100644 index 71bac8ea908..00000000000 --- a/extensions/nextcloud-talk/src/signature.test.ts +++ /dev/null @@ -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(); - }); -});