test: trim messaging test hotspots

This commit is contained in:
Peter Steinberger
2026-04-17 03:55:18 +01:00
parent 35fb3f7e1c
commit a2f2e5738e
14 changed files with 282 additions and 290 deletions

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { createAuthRateLimiter } from "openclaw/plugin-sdk/nextcloud-talk";

View File

@@ -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");

View File

@@ -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";

View 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 };
}

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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>;

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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.

View File

@@ -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,

View File

@@ -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));