mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
test: trim messaging test hotspots
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ type SlashHttpHandlerParams = {
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap?: ReadonlyMap<string, string>;
|
||||
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<string> {
|
||||
function readBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
timeoutMs = BODY_READ_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
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<void> => {
|
||||
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;
|
||||
|
||||
1
extensions/nextcloud-talk/src/api.ts
Normal file
1
extensions/nextcloud-talk/src/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createAuthRateLimiter } from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
@@ -11,13 +11,9 @@ const hoisted = vi.hoisted(() => ({
|
||||
monitorNextcloudTalkProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
|
||||
};
|
||||
});
|
||||
vi.mock("./monitor-runtime.js", () => ({
|
||||
monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
|
||||
}));
|
||||
|
||||
const { nextcloudTalkGatewayAdapter } = await import("./gateway.js");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
138
extensions/nextcloud-talk/src/monitor-runtime.ts
Normal file
138
extensions/nextcloud-talk/src/monitor-runtime.ts
Normal file
@@ -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<void>;
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
isBackendAllowed?: (backend: string) => boolean;
|
||||
shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof import("../../runtime-api.js")>("../../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<ReturnType<typeof loadUploadMocks>>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user