diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts index 07c836949cf..d2e1764f5b2 100644 --- a/extensions/mattermost/src/mattermost/slash-http.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { PassThrough } from "node:stream"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import { createSlashCommandHttpHandler } from "./slash-http.js"; @@ -133,25 +133,19 @@ describe("slash-http", () => { }); it("returns 408 when the request body stalls", async () => { - vi.useFakeTimers(); - try { - const handler = createSlashCommandHttpHandler({ - account: accountFixture, - cfg: {} as OpenClawConfig, - runtime: {} as RuntimeEnv, - commandTokens: new Set(["valid-token"]), - }); - const req = createRequest({ autoEnd: false }); - const response = createResponse(); - const pending = handler(req, response.res); + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens: new Set(["valid-token"]), + bodyTimeoutMs: 1, + }); + const req = createRequest({ autoEnd: false }); + const response = createResponse(); - await vi.advanceTimersByTimeAsync(5_000); - await pending; + await handler(req, response.res); - expect(response.res.statusCode).toBe(408); - expect(response.getBody()).toBe("Request body timeout"); - } finally { - vi.useRealTimers(); - } + expect(response.res.statusCode).toBe(408); + expect(response.getBody()).toBe("Request body timeout"); }); }); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 39f52b5e71f..1b7e144b9fd 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -54,6 +54,7 @@ type SlashHttpHandlerParams = { /** Map from trigger to original command name (for skill commands that start with oc_). */ triggerMap?: ReadonlyMap; log?: (msg: string) => void; + bodyTimeoutMs?: number; }; const MAX_BODY_BYTES = 64 * 1024; @@ -62,10 +63,14 @@ const BODY_READ_TIMEOUT_MS = 5_000; /** * Read the full request body as a string. */ -function readBody(req: IncomingMessage, maxBytes: number): Promise { +function readBody( + req: IncomingMessage, + maxBytes: number, + timeoutMs = BODY_READ_TIMEOUT_MS, +): Promise { return readRequestBodyWithLimit(req, { maxBytes, - timeoutMs: BODY_READ_TIMEOUT_MS, + timeoutMs, }); } @@ -219,7 +224,7 @@ async function authorizeSlashInvocation(params: { * from the Mattermost server when a user invokes a registered slash command. */ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { - const { account, cfg, runtime, commandTokens, triggerMap, log } = params; + const { account, cfg, runtime, commandTokens, triggerMap, log, bodyTimeoutMs } = params; return async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== "POST") { @@ -231,7 +236,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { let body: string; try { - body = await readBody(req, MAX_BODY_BYTES); + body = await readBody(req, MAX_BODY_BYTES, bodyTimeoutMs); } catch (error) { if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) { res.statusCode = 408; diff --git a/extensions/nextcloud-talk/src/api.ts b/extensions/nextcloud-talk/src/api.ts new file mode 100644 index 00000000000..06ea03cf295 --- /dev/null +++ b/extensions/nextcloud-talk/src/api.ts @@ -0,0 +1 @@ +export { createAuthRateLimiter } from "openclaw/plugin-sdk/nextcloud-talk"; diff --git a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts index 1f88e5ef8a1..4198010b768 100644 --- a/extensions/nextcloud-talk/src/channel.lifecycle.test.ts +++ b/extensions/nextcloud-talk/src/channel.lifecycle.test.ts @@ -11,13 +11,9 @@ const hoisted = vi.hoisted(() => ({ monitorNextcloudTalkProvider: vi.fn(), })); -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, - monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, - }; -}); +vi.mock("./monitor-runtime.js", () => ({ + monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider, +})); const { nextcloudTalkGatewayAdapter } = await import("./gateway.js"); diff --git a/extensions/nextcloud-talk/src/gateway.ts b/extensions/nextcloud-talk/src/gateway.ts index cf29355b13c..39a4b98cd09 100644 --- a/extensions/nextcloud-talk/src/gateway.ts +++ b/extensions/nextcloud-talk/src/gateway.ts @@ -7,7 +7,7 @@ import { type ChannelPlugin, type OpenClawConfig, } from "./channel-api.js"; -import { monitorNextcloudTalkProvider } from "./monitor.js"; +import { monitorNextcloudTalkProvider } from "./monitor-runtime.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/nextcloud-talk/src/monitor-runtime.ts b/extensions/nextcloud-talk/src/monitor-runtime.ts new file mode 100644 index 00000000000..5c962bb3dcc --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor-runtime.ts @@ -0,0 +1,138 @@ +import os from "node:os"; +import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { + createNextcloudTalkWebhookServer, + processNextcloudTalkReplayGuardedMessage, +} from "./monitor.js"; +import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; + +const DEFAULT_WEBHOOK_PORT = 8788; +const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; +const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; + +function normalizeOrigin(value: string): string | null { + try { + return normalizeLowercaseStringOrEmpty(new URL(value).origin); + } catch { + return null; + } +} + +export type NextcloudTalkMonitorOptions = { + accountId?: string; + config?: CoreConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +export async function monitorNextcloudTalkProvider( + opts: NextcloudTalkMonitorOptions, +): Promise<{ stop: () => void }> { + const core = getNextcloudTalkRuntime(); + const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const runtime: RuntimeEnv = resolveLoggerBackedRuntime( + opts.runtime, + core.logging.getChildLogger(), + ); + + if (!account.secret) { + throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); + } + + const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT; + const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST; + const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH; + + const logger = core.logging.getChildLogger({ + channel: "nextcloud-talk", + accountId: account.accountId, + }); + const expectedBackendOrigin = normalizeOrigin(account.baseUrl); + const replayGuard = createNextcloudTalkReplayGuard({ + stateDir: core.state.resolveStateDir(process.env, os.homedir), + onDiskError: (error) => { + logger.warn( + `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`, + ); + }, + }); + + const { start, stop } = createNextcloudTalkWebhookServer({ + port, + host, + path, + secret: account.secret, + isBackendAllowed: (backend) => { + if (!expectedBackendOrigin) { + return true; + } + const backendOrigin = normalizeOrigin(backend); + return backendOrigin === expectedBackendOrigin; + }, + processMessage: async (message) => { + const result = await processNextcloudTalkReplayGuardedMessage({ + replayGuard, + accountId: account.accountId, + message, + handleMessage: async () => { + core.channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "inbound", + at: message.timestamp, + }); + if (opts.onMessage) { + await opts.onMessage(message); + } else { + await handleNextcloudTalkInbound({ + message, + account, + config: cfg, + runtime, + statusSink: opts.statusSink, + }); + } + }, + }); + if (result === "duplicate") { + logger.warn( + `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`, + ); + return; + } + }, + onMessage: async () => {}, + onError: (error) => { + logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`); + }, + abortSignal: opts.abortSignal, + }); + + if (opts.abortSignal?.aborted) { + return { stop }; + } + await start(); + if (opts.abortSignal?.aborted) { + stop(); + return { stop }; + } + + const publicUrl = + account.config.webhookPublicUrl ?? + `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; + logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`); + + return { stop }; +} diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 923a0971224..509477720c1 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js"; -import { WEBHOOK_RATE_LIMIT_DEFAULTS } from "../runtime-api.js"; import { NextcloudTalkRetryableWebhookError, processNextcloudTalkReplayGuardedMessage, @@ -274,8 +273,10 @@ describe("createNextcloudTalkWebhookServer payload validation", () => { describe("createNextcloudTalkWebhookServer auth rate limiting", () => { it("rate limits repeated invalid signature attempts from the same source", async () => { + const maxRequests = 2; const harness = await startWebhookServer({ path: "/nextcloud-auth-rate-limit", + authRateLimit: { maxRequests }, onMessage: vi.fn(), }); const { body, headers } = createSignedCreateMessageRequest(); @@ -286,7 +287,7 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => { let firstResponse: Response | undefined; let lastResponse: Response | undefined; - for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) { + for (let attempt = 0; attempt <= maxRequests; attempt += 1) { const response = await fetch(harness.webhookUrl, { method: "POST", headers: invalidHeaders, @@ -306,14 +307,16 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => { }); it("does not rate limit valid signed webhook bursts from the same source", async () => { + const maxRequests = 2; const harness = await startWebhookServer({ path: "/nextcloud-auth-rate-limit-valid", + authRateLimit: { maxRequests }, onMessage: vi.fn(), }); const { body, headers } = createSignedCreateMessageRequest(); let lastResponse: Response | undefined; - for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) { + for (let attempt = 0; attempt <= maxRequests; attempt += 1) { lastResponse = await fetch(harness.webhookUrl, { method: "POST", headers, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 48202750668..9ebff45cc62 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,35 +1,22 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import os from "node:os"; -import { - resolveLoggerBackedRuntime, - safeParseJsonWithSchema, -} from "openclaw/plugin-sdk/extension-shared"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { z } from "zod"; +import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared"; import { WEBHOOK_RATE_LIMIT_DEFAULTS, - createAuthRateLimiter, - type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, -} from "../runtime-api.js"; -import { resolveNextcloudTalkAccount } from "./accounts.js"; -import { handleNextcloudTalkInbound } from "./inbound.js"; -import { createNextcloudTalkReplayGuard, type NextcloudTalkReplayGuard } from "./replay-guard.js"; -import { getNextcloudTalkRuntime } from "./runtime.js"; +} from "openclaw/plugin-sdk/webhook-ingress"; +import { z } from "zod"; +import { createAuthRateLimiter } from "./api.js"; +import type { NextcloudTalkReplayGuard } from "./replay-guard.js"; import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; import type { - CoreConfig, NextcloudTalkInboundMessage, NextcloudTalkWebhookHeaders, NextcloudTalkWebhookPayload, NextcloudTalkWebhookServerOptions, } from "./types.js"; -const DEFAULT_WEBHOOK_PORT = 8788; -const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; -const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024; const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000; @@ -122,14 +109,6 @@ function formatError(err: unknown): string { return typeof err === "string" ? err : JSON.stringify(err); } -function normalizeOrigin(value: string): string | null { - try { - return normalizeLowercaseStringOrEmpty(new URL(value).origin); - } catch { - return null; - } -} - function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body); } @@ -262,12 +241,20 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe const isBackendAllowed = opts.isBackendAllowed; const shouldProcessMessage = opts.shouldProcessMessage; const processMessage = opts.processMessage; + const authRateLimitMaxRequests = + typeof opts.authRateLimit?.maxRequests === "number" + ? opts.authRateLimit.maxRequests + : WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; + const authRateLimitWindowMs = + typeof opts.authRateLimit?.windowMs === "number" + ? opts.authRateLimit.windowMs + : WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs; const webhookAuthRateLimiter = createAuthRateLimiter({ - maxAttempts: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, - windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, - lockoutMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + maxAttempts: authRateLimitMaxRequests, + windowMs: authRateLimitWindowMs, + lockoutMs: authRateLimitWindowMs, exemptLoopback: false, - pruneIntervalMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + pruneIntervalMs: authRateLimitWindowMs, }); const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { @@ -396,116 +383,3 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe return { server, start, stop }; } - -export type NextcloudTalkMonitorOptions = { - accountId?: string; - config?: CoreConfig; - runtime?: RuntimeEnv; - abortSignal?: AbortSignal; - onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; -}; - -export async function monitorNextcloudTalkProvider( - opts: NextcloudTalkMonitorOptions, -): Promise<{ stop: () => void }> { - const core = getNextcloudTalkRuntime(); - const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); - const account = resolveNextcloudTalkAccount({ - cfg, - accountId: opts.accountId, - }); - const runtime: RuntimeEnv = resolveLoggerBackedRuntime( - opts.runtime, - core.logging.getChildLogger(), - ); - - if (!account.secret) { - throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); - } - - const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT; - const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST; - const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH; - - const logger = core.logging.getChildLogger({ - channel: "nextcloud-talk", - accountId: account.accountId, - }); - const expectedBackendOrigin = normalizeOrigin(account.baseUrl); - const replayGuard = createNextcloudTalkReplayGuard({ - stateDir: core.state.resolveStateDir(process.env, os.homedir), - onDiskError: (error) => { - logger.warn( - `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`, - ); - }, - }); - - const { start, stop } = createNextcloudTalkWebhookServer({ - port, - host, - path, - secret: account.secret, - isBackendAllowed: (backend) => { - if (!expectedBackendOrigin) { - return true; - } - const backendOrigin = normalizeOrigin(backend); - return backendOrigin === expectedBackendOrigin; - }, - processMessage: async (message) => { - const result = await processNextcloudTalkReplayGuardedMessage({ - replayGuard, - accountId: account.accountId, - message, - handleMessage: async () => { - core.channel.activity.record({ - channel: "nextcloud-talk", - accountId: account.accountId, - direction: "inbound", - at: message.timestamp, - }); - if (opts.onMessage) { - await opts.onMessage(message); - } else { - await handleNextcloudTalkInbound({ - message, - account, - config: cfg, - runtime, - statusSink: opts.statusSink, - }); - } - }, - }); - if (result === "duplicate") { - logger.warn( - `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`, - ); - return; - } - }, - onMessage: async () => {}, - onError: (error) => { - logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`); - }, - abortSignal: opts.abortSignal, - }); - - if (opts.abortSignal?.aborted) { - return { stop }; - } - await start(); - if (opts.abortSignal?.aborted) { - stop(); - return { stop }; - } - - const publicUrl = - account.config.webhookPublicUrl ?? - `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; - logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`); - - return { stop }; -} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 90c222185e2..b58a36182e3 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -179,6 +179,10 @@ export type NextcloudTalkWebhookServerOptions = { path: string; secret: string; maxBodyBytes?: number; + authRateLimit?: { + maxRequests?: number; + windowMs?: number; + }; readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise; isBackendAllowed?: (backend: string) => boolean; shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index ab1b56c3c52..a68ef3e8100 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; @@ -42,12 +42,18 @@ const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlu describe("createSynologyChatPlugin", () => { beforeEach(() => { + vi.stubEnv("SYNOLOGY_CHAT_TOKEN", ""); + vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", ""); mockSendMessage.mockClear(); registerSynologyWebhookRouteMock.mockClear(); mockSendMessage.mockResolvedValue(true); registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn()); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + describe("meta", () => { it("has correct id and label", () => { const plugin = createSynologyChatPlugin(); @@ -480,11 +486,17 @@ describe("createSynologyChatPlugin", () => { abortController: AbortController, ) { expect(result).toBeInstanceOf(Promise); - const resolved = await Promise.race([ - result, - new Promise((r) => setTimeout(() => r("pending"), 50)), - ]); - expect(resolved).toBe("pending"); + let settled = false; + void result.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); abortController.abort(); await result; } @@ -584,8 +596,6 @@ describe("createSynologyChatPlugin", () => { const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst)); const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond)); - await new Promise((r) => setTimeout(r, 10)); - expect(registerMock).toHaveBeenCalledTimes(2); expect(unregisterFirst).not.toHaveBeenCalled(); expect(unregisterSecond).not.toHaveBeenCalled(); diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 896c53164bf..f2cbb3488bf 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -144,26 +144,19 @@ describe("createWebhookHandler", () => { }); it("returns 408 when request body times out", async () => { - vi.useFakeTimers(); - try { - const handler = createWebhookHandler({ - account: makeAccount(), - deliver: vi.fn(), - log, - }); + const handler = createWebhookHandler({ + account: makeAccount(), + deliver: vi.fn(), + log, + bodyTimeoutMs: 1, + }); - const req = makeStalledReq("POST"); - const res = makeRes(); - const run = handler(req, res); + const req = makeStalledReq("POST"); + const res = makeRes(); + await handler(req, res); - await vi.advanceTimersByTimeAsync(30_000); - await run; - - expect(res._status).toBe(408); - expect(res._body).toContain("timeout"); - } finally { - vi.useRealTimers(); - } + expect(res._status).toBe(408); + expect(res._body).toContain("timeout"); }); it("rejects excess concurrent pre-auth body reads from the same remote IP", async () => { diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 8064143255a..00c89b32f80 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -142,7 +142,10 @@ function getSynologyWebhookInFlightKey(account: ResolvedSynologyChatAccount): st } /** Read the full request body as a string. */ -async function readBody(req: IncomingMessage): Promise< +async function readBody( + req: IncomingMessage, + timeoutMs = PREAUTH_BODY_TIMEOUT_MS, +): Promise< | { ok: true; body: string } | { ok: false; @@ -153,7 +156,7 @@ async function readBody(req: IncomingMessage): Promise< try { const body = await readRequestBodyWithLimit(req, { maxBytes: PREAUTH_MAX_BODY_BYTES, - timeoutMs: PREAUTH_BODY_TIMEOUT_MS, + timeoutMs, }); return { ok: true, body }; } catch (err) { @@ -342,6 +345,7 @@ export interface WebhookHandlerDeps { warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; }; + bodyTimeoutMs?: number; } /** @@ -371,8 +375,9 @@ async function parseWebhookPayloadRequest(params: { req: IncomingMessage; res: ServerResponse; log?: WebhookHandlerDeps["log"]; + bodyTimeoutMs?: number; }): Promise<{ ok: false } | { ok: true; payload: SynologyWebhookPayload }> { - const bodyResult = await readBody(params.req); + const bodyResult = await readBody(params.req, params.bodyTimeoutMs); if (!bodyResult.ok) { params.log?.error("Failed to read request body", bodyResult.error); respondJson(params.res, bodyResult.statusCode, { error: bodyResult.error }); @@ -465,6 +470,7 @@ async function parseAndAuthorizeSynologyWebhook(params: { invalidTokenRateLimiter: InvalidTokenRateLimiter; rateLimiter: RateLimiter; log?: WebhookHandlerDeps["log"]; + bodyTimeoutMs?: number; }): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> { const parsed = await parseWebhookPayloadRequest(params); if (!parsed.ok) { @@ -612,6 +618,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { invalidTokenRateLimiter, rateLimiter, log, + bodyTimeoutMs: deps.bodyTimeoutMs, }); } finally { // Only bound the pre-auth request pipeline; async reply delivery is outside webhook ingress. diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index 90c196e6813..452ccac426f 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -1,82 +1,65 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { fetchWithSsrFGuard } from "../../runtime-api.js"; +import { uploadFile } from "../tlon-api.js"; +import { uploadImageFromUrl } from "./upload.js"; -// Mock fetchWithSsrFGuard from the local runtime seam. -vi.mock("../../runtime-api.js", async () => { - const actual = - await vi.importActual("../../runtime-api.js"); - return { - ...actual, - fetchWithSsrFGuard: vi.fn(), - }; -}); +vi.mock("../../runtime-api.js", () => ({ + fetchWithSsrFGuard: vi.fn(), +})); -// Mock the local Tlon upload seam. vi.mock("../tlon-api.js", () => ({ uploadFile: vi.fn(), })); +const mockFetch = vi.mocked(fetchWithSsrFGuard); +const mockUploadFile = vi.mocked(uploadFile); + +type FetchMock = typeof mockFetch; + +function mockSuccessfulFetch(params: { + mockFetch: FetchMock; + blob: Blob; + finalUrl: string; + contentType: string; +}) { + params.mockFetch.mockResolvedValue({ + response: { + ok: true, + headers: new Headers({ "content-type": params.contentType }), + blob: () => Promise.resolve(params.blob), + } as unknown as Response, + finalUrl: params.finalUrl, + release: vi.fn().mockResolvedValue(undefined), + }); +} + +async function setupSuccessfulUpload(params?: { + sourceUrl?: string; + contentType?: string; + uploadedUrl?: string; +}) { + const sourceUrl = params?.sourceUrl ?? "https://example.com/image.png"; + const contentType = params?.contentType ?? "image/png"; + const mockBlob = new Blob(["fake-image"], { type: contentType }); + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, + finalUrl: sourceUrl, + contentType, + }); + if (params?.uploadedUrl) { + mockUploadFile.mockResolvedValue({ url: params.uploadedUrl }); + } + return { mockBlob }; +} + describe("uploadImageFromUrl", () => { - async function loadUploadMocks() { - const { fetchWithSsrFGuard } = await import("../../runtime-api.js"); - const { uploadFile } = await import("../tlon-api.js"); - const { uploadImageFromUrl } = await import("./upload.js"); - return { - mockFetch: vi.mocked(fetchWithSsrFGuard), - mockUploadFile: vi.mocked(uploadFile), - uploadImageFromUrl, - }; - } - - type UploadMocks = Awaited>; - - function mockSuccessfulFetch(params: { - mockFetch: UploadMocks["mockFetch"]; - blob: Blob; - finalUrl: string; - contentType: string; - }) { - params.mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": params.contentType }), - blob: () => Promise.resolve(params.blob), - } as unknown as Response, - finalUrl: params.finalUrl, - release: vi.fn().mockResolvedValue(undefined), - }); - } - - async function setupSuccessfulUpload(params?: { - sourceUrl?: string; - contentType?: string; - uploadedUrl?: string; - }) { - const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const sourceUrl = params?.sourceUrl ?? "https://example.com/image.png"; - const contentType = params?.contentType ?? "image/png"; - const mockBlob = new Blob(["fake-image"], { type: contentType }); - mockSuccessfulFetch({ - mockFetch, - blob: mockBlob, - finalUrl: sourceUrl, - contentType, - }); - if (params?.uploadedUrl) { - mockUploadFile.mockResolvedValue({ url: params.uploadedUrl }); - } - return { mockBlob, mockUploadFile, uploadImageFromUrl }; - } - beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it("fetches image and calls uploadFile, returns uploaded URL", async () => { - const { mockBlob, mockUploadFile, uploadImageFromUrl } = await setupSuccessfulUpload({ + const { mockBlob } = await setupSuccessfulUpload({ uploadedUrl: "https://memex.tlon.network/uploaded.png", }); @@ -93,8 +76,6 @@ describe("uploadImageFromUrl", () => { }); it("returns original URL if fetch fails", async () => { - const { mockFetch, uploadImageFromUrl } = await loadUploadMocks(); - mockFetch.mockResolvedValue({ response: { ok: false, @@ -110,7 +91,7 @@ describe("uploadImageFromUrl", () => { }); it("returns original URL if upload fails", async () => { - const { mockUploadFile, uploadImageFromUrl } = await setupSuccessfulUpload(); + await setupSuccessfulUpload(); mockUploadFile.mockRejectedValue(new Error("Upload failed")); const result = await uploadImageFromUrl("https://example.com/image.png"); @@ -119,28 +100,19 @@ describe("uploadImageFromUrl", () => { }); it("rejects non-http(s) URLs", async () => { - const { uploadImageFromUrl } = await import("./upload.js"); - - // file:// URL should be rejected const result = await uploadImageFromUrl("file:///etc/passwd"); expect(result).toBe("file:///etc/passwd"); - // ftp:// URL should be rejected const result2 = await uploadImageFromUrl("ftp://example.com/image.png"); expect(result2).toBe("ftp://example.com/image.png"); }); it("handles invalid URLs gracefully", async () => { - const { uploadImageFromUrl } = await import("./upload.js"); - - // Invalid URL should return original const result = await uploadImageFromUrl("not-a-valid-url"); expect(result).toBe("not-a-valid-url"); }); it("extracts filename from URL path", async () => { - const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" }); mockSuccessfulFetch({ mockFetch, @@ -161,8 +133,6 @@ describe("uploadImageFromUrl", () => { }); it("uses default filename when URL has no path", async () => { - const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const mockBlob = new Blob(["fake-image"], { type: "image/png" }); mockSuccessfulFetch({ mockFetch, diff --git a/test/vitest/vitest.extension-messaging-paths.mjs b/test/vitest/vitest.extension-messaging-paths.mjs index 83bfe2cbf22..ffd11df7122 100644 --- a/test/vitest/vitest.extension-messaging-paths.mjs +++ b/test/vitest/vitest.extension-messaging-paths.mjs @@ -1,16 +1,13 @@ import { bundledPluginRoot } from "../../scripts/lib/bundled-plugin-paths.mjs"; export const messagingExtensionIds = [ - "bluebubbles", "googlechat", - "mattermost", "nextcloud-talk", "nostr", "qqbot", "synology-chat", "tlon", "twitch", - "voice-call", ]; export const messagingExtensionTestRoots = messagingExtensionIds.map((id) => bundledPluginRoot(id));