// Nextcloud Talk tests cover bot preflight plugin behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ fetchWithSsrFGuard: vi.fn(), ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined), })); vi.mock("../runtime-api.js", () => ({ fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard, })); vi.mock("./send.runtime.js", () => ({ ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn, })); const { probeNextcloudTalkBotResponseFeature } = await import("./bot-preflight.js"); function account( overrides: Partial = {}, ): ResolvedNextcloudTalkAccount { return { accountId: "default", enabled: true, baseUrl: "https://cloud.example.com", secret: "secret", secretSource: "config", config: { baseUrl: "https://cloud.example.com", botSecret: "secret", apiUser: "admin", apiPassword: "app-password", webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook", }, ...overrides, }; } function cancelTrackedResponse( text: string, init: ResponseInit, ): { response: Response; wasCanceled: () => boolean; } { let canceled = false; const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(text)); }, cancel() { canceled = true; }, }); return { response: new Response(stream, init), wasCanceled: () => canceled, }; } function mockBotAdmin(features: number | string): void { hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({ response: new Response( JSON.stringify({ ocs: { data: [ { id: 7, name: "OpenClaw", url: "https://bot.example.com/nextcloud-talk-webhook", features, }, ], }, }), { status: 200, headers: { "content-type": "application/json" } }, ), release: async () => {}, finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin", }); } describe("probeNextcloudTalkBotResponseFeature", () => { beforeEach(() => { hoisted.fetchWithSsrFGuard.mockClear(); }); afterEach(() => { hoisted.fetchWithSsrFGuard.mockReset(); }); it("passes when the matching bot has the response feature bit", async () => { mockBotAdmin(1 | 2 | 8); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: true, code: "ok", botId: "7", botName: "OpenClaw", features: 11, message: 'Nextcloud Talk bot "OpenClaw" has the response feature.', }); }); it("normalizes signed decimal bot feature strings through the shared parser", async () => { mockBotAdmin("+011"); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: true, code: "ok", botId: "7", botName: "OpenClaw", features: 11, message: 'Nextcloud Talk bot "OpenClaw" has the response feature.', }); }); it("reports missing response feature for the matching webhook bot", async () => { mockBotAdmin(1 | 8); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: false, code: "missing_response_feature", botId: "7", botName: "OpenClaw", features: 9, message: 'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature (features=9); outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.', }); }); it("does not coerce partial bot feature strings", async () => { mockBotAdmin("2response"); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: false, code: "missing_response_feature", botId: "7", botName: "OpenClaw", message: 'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.', }); }); it("does not treat negative feature masks as having every feature", async () => { mockBotAdmin(-1); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: false, code: "missing_response_feature", botId: "7", botName: "OpenClaw", message: 'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.', }); }); it("reports malformed bot admin JSON with a stable channel error", async () => { hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({ response: new Response("{ nope", { status: 200, headers: { "content-type": "application/json" }, }), release: async () => {}, finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin", }); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: false, code: "request_failed", message: "Nextcloud Talk bot response feature probe failed: Nextcloud Talk bot response feature probe failed: malformed JSON response", }); }); it("bounds bot admin error bodies without using response.text()", async () => { const tracked = cancelTrackedResponse(`${"nextcloud bot admin failure ".repeat(1024)}tail`, { status: 503, headers: { "content-type": "text/plain" }, }); const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({ response: tracked.response, release: async () => {}, finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin", }); await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({ ok: false, code: "api_error", status: 503, message: expect.stringContaining( "Nextcloud Talk bot response feature probe failed (503): nextcloud bot admin failure", ), }); expect(textSpy).not.toHaveBeenCalled(); expect(tracked.wasCanceled()).toBe(true); }); it("skips when API credentials are absent", async () => { await expect( probeNextcloudTalkBotResponseFeature({ account: account({ config: { baseUrl: "https://cloud.example.com", botSecret: "secret", webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook", }, }), }), ).resolves.toEqual({ ok: true, skipped: true, code: "missing_api_credentials", message: "Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured.", }); expect(hoisted.fetchWithSsrFGuard).not.toHaveBeenCalled(); }); });