From efafbece17be8fb6fd57027692d50dbdeda14951 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 05:38:26 +0000 Subject: [PATCH] test: collapse nextcloud-talk send and helper suites --- .../nextcloud-talk/src/accounts.test.ts | 53 ------ extensions/nextcloud-talk/src/core.test.ts | 114 +++++++++++- .../nextcloud-talk/src/room-info.test.ts | 110 ------------ extensions/nextcloud-talk/src/send.test.ts | 98 ----------- extensions/nextcloud-talk/src/setup.test.ts | 163 +++++++++++++++++- 5 files changed, 275 insertions(+), 263 deletions(-) delete mode 100644 extensions/nextcloud-talk/src/accounts.test.ts delete mode 100644 extensions/nextcloud-talk/src/room-info.test.ts delete mode 100644 extensions/nextcloud-talk/src/send.test.ts diff --git a/extensions/nextcloud-talk/src/accounts.test.ts b/extensions/nextcloud-talk/src/accounts.test.ts deleted file mode 100644 index 77e35bf65fb..00000000000 --- a/extensions/nextcloud-talk/src/accounts.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveNextcloudTalkAccount } from "./accounts.js"; -import type { CoreConfig } from "./types.js"; - -describe("resolveNextcloudTalkAccount", () => { - it("matches normalized configured account ids", () => { - const account = resolveNextcloudTalkAccount({ - cfg: { - channels: { - "nextcloud-talk": { - accounts: { - "Ops Team": { - baseUrl: "https://cloud.example.com", - botSecret: "bot-secret", - }, - }, - }, - }, - } as CoreConfig, - accountId: "ops-team", - }); - - expect(account.accountId).toBe("ops-team"); - expect(account.baseUrl).toBe("https://cloud.example.com"); - expect(account.secret).toBe("bot-secret"); - expect(account.secretSource).toBe("config"); - }); - - it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-")); - const secretFile = path.join(dir, "secret.txt"); - const secretLink = path.join(dir, "secret-link.txt"); - fs.writeFileSync(secretFile, "bot-secret\n", "utf8"); - fs.symlinkSync(secretFile, secretLink); - - const cfg = { - channels: { - "nextcloud-talk": { - baseUrl: "https://cloud.example.com", - botSecretFile: secretLink, - }, - }, - } as CoreConfig; - - const account = resolveNextcloudTalkAccount({ cfg }); - expect(account.secret).toBe(""); - expect(account.secretSource).toBe("none"); - fs.rmSync(dir, { recursive: true, force: true }); - }); -}); diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index ef67ebe421f..e106aa20cac 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -1,7 +1,7 @@ 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 { afterEach, describe, expect, it, vi } from "vitest"; import { nextcloudTalkPlugin } from "./channel.js"; import { NextcloudTalkConfigSchema } from "./config-schema.js"; import { @@ -28,9 +28,30 @@ import { } from "./signature.js"; import type { CoreConfig } from "./types.js"; +const fetchWithSsrFGuard = vi.hoisted(() => vi.fn()); +const readFileSync = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime-api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard, + }; +}); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync, + }; +}); + const tempDirs: string[] = []; afterEach(async () => { + fetchWithSsrFGuard.mockReset(); + readFileSync.mockReset(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -383,4 +404,95 @@ describe("nextcloud talk core", () => { innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" }, }); }); + + it("resolves direct rooms from the room info endpoint", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuard.mockResolvedValue({ + response: { + ok: true, + json: async () => ({ + ocs: { + data: { + type: 1, + }, + }, + }), + }, + release, + }); + + const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); + const kind = await resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-direct", + baseUrl: "https://nc.example.com", + config: { + apiUser: "bot", + apiPassword: "secret", + }, + } as never, + roomToken: "room-direct", + }); + + expect(kind).toBe("direct"); + expect(fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct", + auditContext: "nextcloud-talk.room-info", + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("reads the api password from a file and logs non-ok room info responses", async () => { + const release = vi.fn(async () => {}); + const log = vi.fn(); + const error = vi.fn(); + const exit = vi.fn(); + readFileSync.mockReturnValue("file-secret\n"); + fetchWithSsrFGuard.mockResolvedValue({ + response: { + ok: false, + status: 403, + json: async () => ({}), + }, + release, + }); + + const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); + const kind = await resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-group", + baseUrl: "https://nc.example.com", + config: { + apiUser: "bot", + apiPasswordFile: "/tmp/nextcloud-secret", + }, + } as never, + roomToken: "room-group", + runtime: { log, error, exit }, + }); + + expect(kind).toBeUndefined(); + expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8"); + expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("returns undefined from room info without credentials or base url", async () => { + const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); + + await expect( + resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-missing", + baseUrl: "", + config: {}, + } as never, + roomToken: "room-missing", + }), + ).resolves.toBeUndefined(); + + expect(fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/nextcloud-talk/src/room-info.test.ts b/extensions/nextcloud-talk/src/room-info.test.ts deleted file mode 100644 index 1fd42d2f68c..00000000000 --- a/extensions/nextcloud-talk/src/room-info.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const fetchWithSsrFGuard = vi.fn(); -const readFileSync = vi.fn(); - -vi.mock("../runtime-api.js", () => ({ - fetchWithSsrFGuard, -})); - -vi.mock("node:fs", () => ({ - readFileSync, -})); - -describe("nextcloud talk room info", () => { - afterEach(() => { - fetchWithSsrFGuard.mockReset(); - readFileSync.mockReset(); - }); - - it("resolves direct rooms from the room info endpoint", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuard.mockResolvedValue({ - response: { - ok: true, - json: async () => ({ - ocs: { - data: { - type: 1, - }, - }, - }), - }, - release, - }); - - const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); - const kind = await resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-direct", - baseUrl: "https://nc.example.com", - config: { - apiUser: "bot", - apiPassword: "secret", - }, - } as never, - roomToken: "room-direct", - }); - - expect(kind).toBe("direct"); - expect(fetchWithSsrFGuard).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct", - auditContext: "nextcloud-talk.room-info", - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("reads the api password from a file and logs non-ok responses", async () => { - const release = vi.fn(async () => {}); - const log = vi.fn(); - const error = vi.fn(); - const exit = vi.fn(); - readFileSync.mockReturnValue("file-secret\n"); - fetchWithSsrFGuard.mockResolvedValue({ - response: { - ok: false, - status: 403, - json: async () => ({}), - }, - release, - }); - - const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); - const kind = await resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-group", - baseUrl: "https://nc.example.com", - config: { - apiUser: "bot", - apiPasswordFile: "/tmp/nextcloud-secret", - }, - } as never, - roomToken: "room-group", - runtime: { log, error, exit }, - }); - - expect(kind).toBeUndefined(); - expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8"); - expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("returns undefined without credentials or base url", async () => { - const { resolveNextcloudTalkRoomKind } = await import("./room-info.js"); - - await expect( - resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-missing", - baseUrl: "", - config: {}, - } as never, - roomToken: "room-missing", - }), - ).resolves.toBeUndefined(); - - expect(fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts deleted file mode 100644 index b82ac1c4309..00000000000 --- a/extensions/nextcloud-talk/src/send.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - createSendCfgThreadingRuntime, - expectProvidedCfgSkipsRuntimeLoad, - expectRuntimeCfgFallback, -} from "../../../test/helpers/extensions/send-config.js"; - -const hoisted = vi.hoisted(() => ({ - loadConfig: vi.fn(), - resolveMarkdownTableMode: vi.fn(() => "preserve"), - convertMarkdownTables: vi.fn((text: string) => text), - record: vi.fn(), - resolveNextcloudTalkAccount: vi.fn(() => ({ - accountId: "default", - baseUrl: "https://nextcloud.example.com", - secret: "secret-value", // pragma: allowlist secret - })), - generateNextcloudTalkSignature: vi.fn(() => ({ - random: "r", - signature: "s", - })), -})); - -vi.mock("./runtime.js", () => ({ - getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), -})); - -vi.mock("./accounts.js", () => ({ - resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, -})); - -vi.mock("./signature.js", () => ({ - generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, -})); - -import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js"; - -describe("nextcloud-talk send cfg threading", () => { - const fetchMock = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { - const cfg = { source: "provided" } as const; - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ocs: { data: { id: 12345, timestamp: 1_706_000_000 } }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - - const result = await sendMessageNextcloudTalk("room:abc123", "hello", { - cfg, - accountId: "work", - }); - - expectProvidedCfgSkipsRuntimeLoad({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg, - accountId: "work", - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - messageId: "12345", - roomToken: "abc123", - timestamp: 1_706_000_000, - }); - }); - - it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { - const runtimeCfg = { source: "runtime" } as const; - hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); - fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); - - const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { - accountId: "default", - }); - - expect(result).toEqual({ ok: true }); - expectRuntimeCfgFallback({ - loadConfig: hoisted.loadConfig, - resolveAccount: hoisted.resolveNextcloudTalkAccount, - cfg: runtimeCfg, - accountId: "default", - }); - }); -}); diff --git a/extensions/nextcloud-talk/src/setup.test.ts b/extensions/nextcloud-talk/src/setup.test.ts index 410a0223e35..38873e9bf8e 100644 --- a/extensions/nextcloud-talk/src/setup.test.ts +++ b/extensions/nextcloud-talk/src/setup.test.ts @@ -1,12 +1,20 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createSendCfgThreadingRuntime, + expectProvidedCfgSkipsRuntimeLoad, + expectRuntimeCfgFallback, +} from "../../../test/helpers/extensions/send-config.js"; import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; import { expectStopPendingUntilAbort, startAccountAndTrackLifecycle, waitForStartedMocks, } from "../../../test/helpers/extensions/start-account-lifecycle.js"; -import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js"; import { nextcloudTalkPlugin } from "./channel.js"; import { clearNextcloudTalkAccountFields, @@ -21,6 +29,15 @@ import type { CoreConfig } from "./types.js"; const hoisted = vi.hoisted(() => ({ monitorNextcloudTalkProvider: vi.fn(), + loadConfig: vi.fn(), + resolveMarkdownTableMode: vi.fn(() => "preserve"), + convertMarkdownTables: vi.fn((text: string) => text), + record: vi.fn(), + resolveNextcloudTalkAccount: vi.fn(), + generateNextcloudTalkSignature: vi.fn(() => ({ + random: "r", + signature: "s", + })), })); vi.mock("./monitor.js", async () => { @@ -31,6 +48,31 @@ vi.mock("./monitor.js", async () => { }; }); +vi.mock("./runtime.js", () => ({ + getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted), +})); + +vi.mock("./accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount, + }; +}); + +vi.mock("./signature.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature, + }; +}); + +const accountsActual = await vi.importActual("./accounts.js"); +hoisted.resolveNextcloudTalkAccount.mockImplementation(accountsActual.resolveNextcloudTalkAccount); + +import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js"; + function buildAccount(): ResolvedNextcloudTalkAccount { return { accountId: "default", @@ -65,6 +107,9 @@ function startNextcloudAccount(abortSignal?: AbortSignal) { describe("nextcloud talk setup", () => { afterEach(() => { vi.clearAllMocks(); + hoisted.resolveNextcloudTalkAccount.mockImplementation( + accountsActual.resolveNextcloudTalkAccount, + ); }); it("normalizes and validates base urls", () => { @@ -319,3 +364,119 @@ describe("nextcloud talk setup", () => { expect(stop).toHaveBeenCalledOnce(); }); }); + +describe("resolveNextcloudTalkAccount", () => { + it("matches normalized configured account ids", () => { + const account = resolveNextcloudTalkAccount({ + cfg: { + channels: { + "nextcloud-talk": { + accounts: { + "Ops Team": { + baseUrl: "https://cloud.example.com", + botSecret: "bot-secret", + }, + }, + }, + }, + } as CoreConfig, + accountId: "ops-team", + }); + + expect(account.accountId).toBe("ops-team"); + expect(account.baseUrl).toBe("https://cloud.example.com"); + expect(account.secret).toBe("bot-secret"); + expect(account.secretSource).toBe("config"); + }); + + it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-")); + const secretFile = path.join(dir, "secret.txt"); + const secretLink = path.join(dir, "secret-link.txt"); + fs.writeFileSync(secretFile, "bot-secret\n", "utf8"); + fs.symlinkSync(secretFile, secretLink); + + const cfg = { + channels: { + "nextcloud-talk": { + baseUrl: "https://cloud.example.com", + botSecretFile: secretLink, + }, + }, + } as CoreConfig; + + const account = resolveNextcloudTalkAccount({ cfg }); + expect(account.secret).toBe(""); + expect(account.secretSource).toBe("none"); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe("nextcloud-talk send cfg threading", () => { + const fetchMock = vi.fn(); + + afterEach(() => { + fetchMock.mockReset(); + vi.unstubAllGlobals(); + }); + + it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => { + const cfg = { source: "provided" } as const; + vi.stubGlobal("fetch", fetchMock); + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", // pragma: allowlist secret + }); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ocs: { data: { id: 12345, timestamp: 1_706_000_000 } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + }); + + expectProvidedCfgSkipsRuntimeLoad({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg, + accountId: "work", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + messageId: "12345", + roomToken: "abc123", + timestamp: 1_706_000_000, + }); + }); + + it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => { + const runtimeCfg = { source: "runtime" } as const; + hoisted.loadConfig.mockReturnValueOnce(runtimeCfg); + vi.stubGlobal("fetch", fetchMock); + hoisted.resolveNextcloudTalkAccount.mockReturnValue({ + accountId: "default", + baseUrl: "https://nextcloud.example.com", + secret: "secret-value", // pragma: allowlist secret + }); + fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); + + const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", { + accountId: "default", + }); + + expect(result).toEqual({ ok: true }); + expectRuntimeCfgFallback({ + loadConfig: hoisted.loadConfig, + resolveAccount: hoisted.resolveNextcloudTalkAccount, + cfg: runtimeCfg, + accountId: "default", + }); + }); +});