mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:30:42 +00:00
refactor: dedupe media and request-body test scaffolding
This commit is contained in:
@@ -113,15 +113,29 @@ describe("http body limits", () => {
|
|||||||
expect(req.__unhandledDestroyError).toBeUndefined();
|
expect(req.__unhandledDestroyError).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("timeout surfaces typed error", async () => {
|
it("timeout surfaces typed error when timeoutMs is clamped", async () => {
|
||||||
const req = createMockRequest({ emitEnd: false });
|
const req = createMockRequest({ emitEnd: false });
|
||||||
const promise = readRequestBodyWithLimit(req, { maxBytes: 128, timeoutMs: 10 });
|
const promise = readRequestBodyWithLimit(req, { maxBytes: 128, timeoutMs: 0 });
|
||||||
await expect(promise).rejects.toSatisfy((error: unknown) =>
|
await expect(promise).rejects.toSatisfy((error: unknown) =>
|
||||||
isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT"),
|
isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT"),
|
||||||
);
|
);
|
||||||
expect(req.__unhandledDestroyError).toBeUndefined();
|
expect(req.__unhandledDestroyError).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("guard clamps invalid maxBytes to one byte", async () => {
|
||||||
|
const req = createMockRequest({ chunks: ["ab"], emitEnd: false });
|
||||||
|
const res = createMockServerResponse();
|
||||||
|
const guard = installRequestBodyLimitGuard(req, res, {
|
||||||
|
maxBytes: Number.NaN,
|
||||||
|
responseFormat: "text",
|
||||||
|
});
|
||||||
|
await waitForMicrotaskTurn();
|
||||||
|
expect(guard.isTripped()).toBe(true);
|
||||||
|
expect(guard.code()).toBe("PAYLOAD_TOO_LARGE");
|
||||||
|
expect(res.statusCode).toBe(413);
|
||||||
|
expect(req.__unhandledDestroyError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("declared oversized content-length does not emit unhandled error", async () => {
|
it("declared oversized content-length does not emit unhandled error", async () => {
|
||||||
const req = createMockRequest({
|
const req = createMockRequest({
|
||||||
headers: { "content-length": "9999" },
|
headers: { "content-length": "9999" },
|
||||||
|
|||||||
@@ -79,10 +79,15 @@ export type ReadRequestBodyOptions = {
|
|||||||
encoding?: BufferEncoding;
|
encoding?: BufferEncoding;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function readRequestBodyWithLimit(
|
type RequestBodyLimitValues = {
|
||||||
req: IncomingMessage,
|
maxBytes: number;
|
||||||
options: ReadRequestBodyOptions,
|
timeoutMs: number;
|
||||||
): Promise<string> {
|
};
|
||||||
|
|
||||||
|
function resolveRequestBodyLimitValues(options: {
|
||||||
|
maxBytes: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): RequestBodyLimitValues {
|
||||||
const maxBytes = Number.isFinite(options.maxBytes)
|
const maxBytes = Number.isFinite(options.maxBytes)
|
||||||
? Math.max(1, Math.floor(options.maxBytes))
|
? Math.max(1, Math.floor(options.maxBytes))
|
||||||
: 1;
|
: 1;
|
||||||
@@ -90,6 +95,14 @@ export async function readRequestBodyWithLimit(
|
|||||||
typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
|
typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
|
||||||
? Math.max(1, Math.floor(options.timeoutMs))
|
? Math.max(1, Math.floor(options.timeoutMs))
|
||||||
: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
|
: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
|
||||||
|
return { maxBytes, timeoutMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRequestBodyWithLimit(
|
||||||
|
req: IncomingMessage,
|
||||||
|
options: ReadRequestBodyOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const { maxBytes, timeoutMs } = resolveRequestBodyLimitValues(options);
|
||||||
const encoding = options.encoding ?? "utf-8";
|
const encoding = options.encoding ?? "utf-8";
|
||||||
|
|
||||||
const declaredLength = parseContentLengthHeader(req);
|
const declaredLength = parseContentLengthHeader(req);
|
||||||
@@ -241,13 +254,7 @@ export function installRequestBodyLimitGuard(
|
|||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
options: RequestBodyLimitGuardOptions,
|
options: RequestBodyLimitGuardOptions,
|
||||||
): RequestBodyLimitGuard {
|
): RequestBodyLimitGuard {
|
||||||
const maxBytes = Number.isFinite(options.maxBytes)
|
const { maxBytes, timeoutMs } = resolveRequestBodyLimitValues(options);
|
||||||
? Math.max(1, Math.floor(options.maxBytes))
|
|
||||||
: 1;
|
|
||||||
const timeoutMs =
|
|
||||||
typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
|
|
||||||
? Math.max(1, Math.floor(options.timeoutMs))
|
|
||||||
: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS;
|
|
||||||
const responseFormat = options.responseFormat ?? "json";
|
const responseFormat = options.responseFormat ?? "json";
|
||||||
const customText = options.responseText ?? {};
|
const customText = options.responseText ?? {};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import * as ssrf from "../../infra/net/ssrf.js";
|
import * as ssrf from "../../infra/net/ssrf.js";
|
||||||
import type { SavedMedia } from "../../media/store.js";
|
import type { SavedMedia } from "../../media/store.js";
|
||||||
import * as mediaStore from "../../media/store.js";
|
import * as mediaStore from "../../media/store.js";
|
||||||
|
import { mockPinnedHostnameResolution } from "../../test-helpers/ssrf.js";
|
||||||
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
import { type FetchMock, withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
||||||
import {
|
import {
|
||||||
fetchWithSlackAuth,
|
fetchWithSlackAuth,
|
||||||
@@ -173,15 +174,7 @@ describe("resolveSlackMedia", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch = vi.fn();
|
mockFetch = vi.fn();
|
||||||
globalThis.fetch = withFetchPreconnect(mockFetch);
|
globalThis.fetch = withFetchPreconnect(mockFetch);
|
||||||
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
mockPinnedHostnameResolution();
|
||||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
||||||
const addresses = ["93.184.216.34"];
|
|
||||||
return {
|
|
||||||
hostname: normalized,
|
|
||||||
addresses,
|
|
||||||
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
14
src/test-helpers/ssrf.ts
Normal file
14
src/test-helpers/ssrf.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
import * as ssrf from "../infra/net/ssrf.js";
|
||||||
|
|
||||||
|
export function mockPinnedHostnameResolution(addresses: string[] = ["93.184.216.34"]) {
|
||||||
|
return vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
const pinnedAddresses = [...addresses];
|
||||||
|
return {
|
||||||
|
hostname: normalized,
|
||||||
|
addresses: pinnedAddresses,
|
||||||
|
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses: pinnedAddresses }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import sharp from "sharp";
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
||||||
import * as ssrf from "../infra/net/ssrf.js";
|
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { optimizeImageToPng } from "../media/image-ops.js";
|
import { optimizeImageToPng } from "../media/image-ops.js";
|
||||||
|
import { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js";
|
||||||
import { captureEnv } from "../test-utils/env.js";
|
import { captureEnv } from "../test-utils/env.js";
|
||||||
import {
|
import {
|
||||||
LocalMediaAccessError,
|
LocalMediaAccessError,
|
||||||
@@ -126,15 +126,7 @@ describe("web media loading", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
mockPinnedHostnameResolution();
|
||||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
||||||
const addresses = ["93.184.216.34"];
|
|
||||||
return {
|
|
||||||
hostname: normalized,
|
|
||||||
addresses,
|
|
||||||
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips MEDIA: prefix before reading local file (including whitespace variants)", async () => {
|
it("strips MEDIA: prefix before reading local file (including whitespace variants)", async () => {
|
||||||
@@ -240,6 +232,18 @@ describe("web media loading", () => {
|
|||||||
fetchMock.mockRestore();
|
fetchMock.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps raw mode when options object sets optimizeImages true", async () => {
|
||||||
|
const { buffer, file } = await createLargeTestJpeg();
|
||||||
|
const cap = Math.max(1, Math.floor(buffer.length * 0.8));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadWebMediaRaw(file, {
|
||||||
|
maxBytes: cap,
|
||||||
|
optimizeImages: true,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Media exceeds/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses content-disposition filename when available", async () => {
|
it("uses content-disposition filename when available", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ type WebMediaOptions = {
|
|||||||
readFile?: (filePath: string) => Promise<Buffer>;
|
readFile?: (filePath: string) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveWebMediaOptions(params: {
|
||||||
|
maxBytesOrOptions?: number | WebMediaOptions;
|
||||||
|
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" };
|
||||||
|
optimizeImages: boolean;
|
||||||
|
}): WebMediaOptions {
|
||||||
|
if (typeof params.maxBytesOrOptions === "number" || params.maxBytesOrOptions === undefined) {
|
||||||
|
return {
|
||||||
|
maxBytes: params.maxBytesOrOptions,
|
||||||
|
optimizeImages: params.optimizeImages,
|
||||||
|
ssrfPolicy: params.options?.ssrfPolicy,
|
||||||
|
localRoots: params.options?.localRoots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...params.maxBytesOrOptions,
|
||||||
|
optimizeImages: params.optimizeImages
|
||||||
|
? (params.maxBytesOrOptions.optimizeImages ?? true)
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type LocalMediaAccessErrorCode =
|
export type LocalMediaAccessErrorCode =
|
||||||
| "path-not-allowed"
|
| "path-not-allowed"
|
||||||
| "invalid-root"
|
| "invalid-root"
|
||||||
@@ -385,18 +406,10 @@ export async function loadWebMedia(
|
|||||||
maxBytesOrOptions?: number | WebMediaOptions,
|
maxBytesOrOptions?: number | WebMediaOptions,
|
||||||
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
return await loadWebMediaInternal(
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
mediaUrl,
|
||||||
maxBytes: maxBytesOrOptions,
|
resolveWebMediaOptions({ maxBytesOrOptions, options, optimizeImages: true }),
|
||||||
optimizeImages: true,
|
);
|
||||||
ssrfPolicy: options?.ssrfPolicy,
|
|
||||||
localRoots: options?.localRoots,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
|
||||||
...maxBytesOrOptions,
|
|
||||||
optimizeImages: maxBytesOrOptions.optimizeImages ?? true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadWebMediaRaw(
|
export async function loadWebMediaRaw(
|
||||||
@@ -404,18 +417,10 @@ export async function loadWebMediaRaw(
|
|||||||
maxBytesOrOptions?: number | WebMediaOptions,
|
maxBytesOrOptions?: number | WebMediaOptions,
|
||||||
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
return await loadWebMediaInternal(
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
mediaUrl,
|
||||||
maxBytes: maxBytesOrOptions,
|
resolveWebMediaOptions({ maxBytesOrOptions, options, optimizeImages: false }),
|
||||||
optimizeImages: false,
|
);
|
||||||
ssrfPolicy: options?.ssrfPolicy,
|
|
||||||
localRoots: options?.localRoots,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
|
||||||
...maxBytesOrOptions,
|
|
||||||
optimizeImages: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function optimizeImageToJpeg(
|
export async function optimizeImageToJpeg(
|
||||||
|
|||||||
Reference in New Issue
Block a user