test: dedupe media utility suites

This commit is contained in:
Peter Steinberger
2026-03-28 07:31:28 +00:00
parent 155915e7dc
commit e7c1fcba0c
20 changed files with 2005 additions and 1175 deletions

View File

@@ -6,36 +6,73 @@ import {
} from "./audio.js";
describe("isVoiceCompatibleAudio", () => {
it.each([
...Array.from(TELEGRAM_VOICE_MIME_TYPES, (contentType) => ({ contentType, fileName: null })),
{ contentType: "audio/ogg; codecs=opus", fileName: null },
{ contentType: "audio/mp4; codecs=mp4a.40.2", fileName: null },
])("returns true for MIME type $contentType", (opts) => {
expect(isVoiceCompatibleAudio(opts)).toBe(true);
});
function expectVoiceCompatibilityCase(
opts: Parameters<typeof isVoiceCompatibleAudio>[0],
expected: boolean,
) {
expect(isVoiceCompatibleAudio(opts)).toBe(expected);
}
it.each(Array.from(TELEGRAM_VOICE_AUDIO_EXTENSIONS))("returns true for extension %s", (ext) => {
expect(isVoiceCompatibleAudio({ fileName: `voice${ext}` })).toBe(true);
});
function expectVoiceCompatibilityCases(
cases: ReadonlyArray<{
opts: Parameters<typeof isVoiceCompatibleAudio>[0];
expected: boolean;
}>,
) {
cases.forEach(({ opts, expected }) => {
expectVoiceCompatibilityCase(opts, expected);
});
}
it.each([
{ contentType: "audio/wav", fileName: null },
{ contentType: "audio/flac", fileName: null },
{ contentType: "audio/aac", fileName: null },
{ contentType: "video/mp4", fileName: null },
])("returns false for unsupported MIME $contentType", (opts) => {
expect(isVoiceCompatibleAudio(opts)).toBe(false);
});
it.each([".wav", ".flac", ".webm"])("returns false for extension %s", (ext) => {
expect(isVoiceCompatibleAudio({ fileName: `audio${ext}` })).toBe(false);
});
it("returns false when no contentType and no fileName", () => {
expect(isVoiceCompatibleAudio({})).toBe(false);
});
it("prefers MIME type over extension", () => {
expect(isVoiceCompatibleAudio({ contentType: "audio/mpeg", fileName: "file.wav" })).toBe(true);
{
name: "returns true for supported MIME types",
cases: [
...Array.from(TELEGRAM_VOICE_MIME_TYPES, (contentType) => ({
opts: { contentType, fileName: null },
expected: true,
})),
{ opts: { contentType: "audio/ogg; codecs=opus", fileName: null }, expected: true },
{ opts: { contentType: "audio/mp4; codecs=mp4a.40.2", fileName: null }, expected: true },
],
},
{
name: "returns true for supported extensions",
cases: Array.from(TELEGRAM_VOICE_AUDIO_EXTENSIONS, (ext) => ({
opts: { fileName: `voice${ext}` },
expected: true,
})),
},
{
name: "returns false for unsupported MIME types",
cases: [
{ opts: { contentType: "audio/wav", fileName: null }, expected: false },
{ opts: { contentType: "audio/flac", fileName: null }, expected: false },
{ opts: { contentType: "audio/aac", fileName: null }, expected: false },
{ opts: { contentType: "video/mp4", fileName: null }, expected: false },
],
},
{
name: "returns false for unsupported extensions",
cases: [".wav", ".flac", ".webm"].map((ext) => ({
opts: { fileName: `audio${ext}` },
expected: false,
})),
},
{
name: "keeps fallback edge cases explicit",
cases: [
{
opts: {},
expected: false,
},
{
opts: { contentType: "audio/mpeg", fileName: "file.wav" },
expected: true,
},
],
},
])("$name", ({ cases }) => {
expectVoiceCompatibilityCases(cases);
});
});

View File

@@ -2,17 +2,32 @@ import { describe, expect, it } from "vitest";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js";
describe("base64 helpers", () => {
it("normalizes whitespace and keeps valid base64", () => {
const input = " SGV s bG8= \n";
expect(canonicalizeBase64(input)).toBe("SGVsbG8=");
});
function expectBase64HelperCase<T>(actual: T, expected: T) {
expect(actual).toBe(expected);
}
it("rejects invalid base64 characters", () => {
const input = 'SGVsbG8=" onerror="alert(1)';
expect(canonicalizeBase64(input)).toBeUndefined();
});
it("estimates decoded bytes with whitespace", () => {
expect(estimateBase64DecodedBytes("SGV s bG8= \n")).toBe(5);
it.each([
{
name: "canonicalizeBase64 normalizes whitespace and keeps valid base64",
actual: canonicalizeBase64(" SGV s bG8= \n"),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 rejects invalid base64 characters",
actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'),
expected: undefined,
},
{
name: "estimateBase64DecodedBytes handles whitespace",
actual: estimateBase64DecodedBytes("SGV s bG8= \n"),
expected: 5,
},
{
name: "estimateBase64DecodedBytes handles empty input",
actual: estimateBase64DecodedBytes(""),
expected: 0,
},
] as const)("$name", ({ actual, expected }) => {
expectBase64HelperCase(actual, expected);
});
});

View File

@@ -39,6 +39,20 @@ function makeLookupFn(): LookupFn {
return vi.fn(async () => ({ address: "149.154.167.220", family: 4 })) as unknown as LookupFn;
}
async function expectRemoteMediaMaxBytesError(params: {
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
maxBytes: number;
}) {
await expect(
fetchRemoteMedia({
url: "https://example.com/file.bin",
fetchImpl: params.fetchImpl,
maxBytes: params.maxBytes,
lookupFn: makeLookupFn(),
}),
).rejects.toThrow("exceeds maxBytes");
}
async function expectRedactedTelegramFetchError(params: {
telegramFileUrl: string;
telegramToken: string;
@@ -62,6 +76,97 @@ async function expectRedactedTelegramFetchError(params: {
expect(errorText).toContain(`bot${params.redactedTelegramToken}`);
}
async function expectFetchRemoteMediaRejected(params: {
url: string;
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
maxBytes?: number;
readIdleTimeoutMs?: number;
lookupFn?: LookupFn;
expectedError: RegExp | string | Record<string, unknown>;
}) {
const rejection = expect(
fetchRemoteMedia({
url: params.url,
fetchImpl: params.fetchImpl,
lookupFn: params.lookupFn ?? makeLookupFn(),
maxBytes: params.maxBytes ?? 1024,
...(params.readIdleTimeoutMs ? { readIdleTimeoutMs: params.readIdleTimeoutMs } : {}),
}),
).rejects;
if (params.expectedError instanceof RegExp || typeof params.expectedError === "string") {
await rejection.toThrow(params.expectedError);
return;
}
await rejection.toMatchObject(params.expectedError);
}
async function expectFetchRemoteMediaResolvesToError(
params: Parameters<typeof fetchRemoteMedia>[0],
): Promise<Error> {
const result = await fetchRemoteMedia(params).catch((err: unknown) => err);
expect(result).toBeInstanceOf(Error);
if (!(result instanceof Error)) {
expect.unreachable("expected fetchRemoteMedia to reject");
}
return result;
}
async function expectFetchRemoteMediaIdleTimeoutCase(params: {
lookupFn: LookupFn;
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"];
readIdleTimeoutMs: number;
expectedError: Record<string, unknown>;
}) {
vi.useFakeTimers();
try {
const rejection = expectFetchRemoteMediaRejected({
url: "https://example.com/file.bin",
fetchImpl: params.fetchImpl,
lookupFn: params.lookupFn,
readIdleTimeoutMs: params.readIdleTimeoutMs,
expectedError: params.expectedError,
});
await vi.advanceTimersByTimeAsync(params.readIdleTimeoutMs + 5);
await rejection;
} finally {
vi.useRealTimers();
}
}
async function expectBoundedErrorBodyCase(
fetchImpl: Parameters<typeof fetchRemoteMedia>[0]["fetchImpl"],
) {
const result = await expectFetchRemoteMediaResolvesToError(
createFetchRemoteMediaParams({
url: "https://example.com/file.bin",
fetchImpl,
}),
);
expect(result.message).not.toContain("BAD");
expect(result.message).not.toContain("body:");
}
async function expectPrivateIpFetchBlockedCase() {
const fetchImpl = vi.fn();
await expectFetchRemoteMediaRejected({
url: "http://127.0.0.1/secret.jpg",
fetchImpl,
expectedError: /private|internal|blocked/i,
});
expect(fetchImpl).not.toHaveBeenCalled();
}
function createFetchRemoteMediaParams(
params: Omit<Parameters<typeof fetchRemoteMedia>[0], "lookupFn"> & { lookupFn?: LookupFn },
) {
return {
lookupFn: params.lookupFn ?? makeLookupFn(),
maxBytes: 1024,
...params,
};
}
describe("fetchRemoteMedia", () => {
const telegramToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
const redactedTelegramToken = `${telegramToken.slice(0, 6)}${telegramToken.slice(-4)}`;
@@ -95,131 +200,91 @@ describe("fetchRemoteMedia", () => {
});
});
it("rejects when content-length exceeds maxBytes", async () => {
const lookupFn = vi.fn(async () => ({
address: "93.184.216.34",
family: 4,
})) as unknown as LookupFn;
const fetchImpl = async () =>
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
status: 200,
headers: { "content-length": "5" },
});
await expect(
fetchRemoteMedia({
url: "https://example.com/file.bin",
fetchImpl,
maxBytes: 4,
lookupFn,
}),
).rejects.toThrow("exceeds maxBytes");
it.each([
{
name: "rejects when content-length exceeds maxBytes",
fetchImpl: async () =>
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
status: 200,
headers: { "content-length": "5" },
}),
},
{
name: "rejects when streamed payload exceeds maxBytes",
fetchImpl: async () =>
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), {
status: 200,
}),
},
] as const)("$name", async ({ fetchImpl }) => {
await expectRemoteMediaMaxBytesError({ fetchImpl, maxBytes: 4 });
});
it("rejects when streamed payload exceeds maxBytes", async () => {
const lookupFn = vi.fn(async () => ({
address: "93.184.216.34",
family: 4,
})) as unknown as LookupFn;
const fetchImpl = async () =>
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), {
status: 200,
});
await expect(
fetchRemoteMedia({
url: "https://example.com/file.bin",
fetchImpl,
maxBytes: 4,
lookupFn,
it.each([
{
name: "redacts Telegram bot tokens from fetch failure messages",
fetchImpl: vi.fn(async () => {
throw new Error(`dial failed for ${telegramFileUrl}`);
}),
).rejects.toThrow("exceeds maxBytes");
},
{
name: "redacts Telegram bot tokens from HTTP error messages",
fetchImpl: vi.fn(async () => new Response("unauthorized", { status: 401 })),
},
] as const)("$name", async ({ fetchImpl }) => {
await expectRedactedTelegramFetchError({
telegramFileUrl,
telegramToken,
redactedTelegramToken,
fetchImpl,
});
});
it("aborts stalled body reads when idle timeout expires", async () => {
vi.useFakeTimers();
try {
const lookupFn = vi.fn(async () => ({
it.each([
{
name: "aborts stalled body reads when idle timeout expires",
lookupFn: vi.fn(async () => ({
address: "93.184.216.34",
family: 4,
})) as unknown as LookupFn;
const fetchImpl = makeStallingFetch(new Uint8Array([1, 2]));
const fetchPromise = fetchRemoteMedia({
url: "https://example.com/file.bin",
fetchImpl,
lookupFn,
maxBytes: 1024,
readIdleTimeoutMs: 20,
});
const rejection = expect(fetchPromise).rejects.toMatchObject({
})) as unknown as LookupFn,
fetchImpl: makeStallingFetch(new Uint8Array([1, 2])),
readIdleTimeoutMs: 20,
expectedError: {
code: "fetch_failed",
name: "MediaFetchError",
});
},
},
] as const)("$name", async ({ lookupFn, fetchImpl, readIdleTimeoutMs, expectedError }) => {
await expectFetchRemoteMediaIdleTimeoutCase({
lookupFn,
fetchImpl,
readIdleTimeoutMs,
expectedError,
});
});
await vi.advanceTimersByTimeAsync(25);
await rejection;
} finally {
vi.useRealTimers();
it.each([
{
name: "bounds error-body snippets instead of reading the full response",
kind: "bounded-error-body" as const,
fetchImpl: vi.fn(
async () =>
new Response(makeStream([new TextEncoder().encode(`${" ".repeat(9_000)}BAD`)]), {
status: 400,
statusText: "Bad Request",
}),
),
},
{
name: "blocks private IP literals before fetching",
kind: "private-ip-block" as const,
},
] as const)("$name", async (testCase) => {
if (testCase.kind === "private-ip-block") {
await expectPrivateIpFetchBlockedCase();
return;
}
}, 5_000);
it("redacts Telegram bot tokens from fetch failure messages", async () => {
const fetchImpl = vi.fn(async () => {
throw new Error(`dial failed for ${telegramFileUrl}`);
});
await expectRedactedTelegramFetchError({
telegramFileUrl,
telegramToken,
redactedTelegramToken,
fetchImpl,
});
});
it("redacts Telegram bot tokens from HTTP error messages", async () => {
const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 }));
await expectRedactedTelegramFetchError({
telegramFileUrl,
telegramToken,
redactedTelegramToken,
fetchImpl,
});
});
it("bounds error-body snippets instead of reading the full response", async () => {
const hiddenTail = `${" ".repeat(9_000)}BAD`;
const fetchImpl = vi.fn(
async () =>
new Response(makeStream([new TextEncoder().encode(hiddenTail)]), {
status: 400,
statusText: "Bad Request",
}),
);
const result = await fetchRemoteMedia({
url: "https://example.com/file.bin",
fetchImpl,
maxBytes: 1024,
}).catch((err: unknown) => err);
expect(result).toBeInstanceOf(Error);
if (!(result instanceof Error)) {
expect.unreachable("expected fetchRemoteMedia to reject");
}
expect(result.message).not.toContain("BAD");
expect(result.message).not.toContain("body:");
});
it("blocks private IP literals before fetching", async () => {
const fetchImpl = vi.fn();
await expect(
fetchRemoteMedia({
url: "http://127.0.0.1/secret.jpg",
fetchImpl,
maxBytes: 1024,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
await expectBoundedErrorBodyCase(testCase.fetchImpl);
});
});

View File

@@ -2,23 +2,44 @@ import { describe, expect, it } from "vitest";
import { parseFfprobeCodecAndSampleRate, parseFfprobeCsvFields } from "./ffmpeg-exec.js";
describe("parseFfprobeCsvFields", () => {
it("splits ffprobe csv output across commas and newlines", () => {
expect(parseFfprobeCsvFields("opus,\n48000\n", 2)).toEqual(["opus", "48000"]);
function expectParsedFfprobeCsvCase(input: string, fieldCount: number, expected: string[]) {
expect(parseFfprobeCsvFields(input, fieldCount)).toEqual(expected);
}
it.each([
{ input: "opus,\n48000\n", fieldCount: 2, expected: ["opus", "48000"] },
{ input: "opus,48000,stereo\n", fieldCount: 3, expected: ["opus", "48000", "stereo"] },
] as const)("splits ffprobe csv output %#", ({ input, fieldCount, expected }) => {
expectParsedFfprobeCsvCase(input, fieldCount, [...expected]);
});
});
describe("parseFfprobeCodecAndSampleRate", () => {
it("parses opus codec and numeric sample rate", () => {
expect(parseFfprobeCodecAndSampleRate("Opus,48000\n")).toEqual({
codec: "opus",
sampleRateHz: 48_000,
});
});
function expectParsedCodecAndSampleRateCase(
input: string,
expected: { codec: string | null; sampleRateHz: number | null },
) {
expect(parseFfprobeCodecAndSampleRate(input)).toEqual(expected);
}
it("returns null sample rate for invalid numeric fields", () => {
expect(parseFfprobeCodecAndSampleRate("opus,not-a-number")).toEqual({
codec: "opus",
sampleRateHz: null,
});
it.each([
{
name: "normalizes codec casing and parses numeric sample rates",
input: "Opus,48000\n",
expected: {
codec: "opus",
sampleRateHz: 48_000,
},
},
{
name: "keeps codec when the sample rate is not numeric",
input: "opus,not-a-number",
expected: {
codec: "opus",
sampleRateHz: null,
},
},
] as const)("$name", ({ input, expected }) => {
expectParsedCodecAndSampleRateCase(input, expected);
});
});

View File

@@ -2,38 +2,64 @@ import { describe, expect, it } from "vitest";
import { renderFileContextBlock } from "./file-context.js";
describe("renderFileContextBlock", () => {
it("escapes filename attributes and file tag markers in content", () => {
const rendered = renderFileContextBlock({
filename: 'test"><file name="INJECTED"',
content: 'before </file> <file name="evil"> after',
function expectRenderedContextContains(rendered: string, expectedSubstrings: readonly string[]) {
expectedSubstrings.forEach((expected) => {
expect(rendered).toContain(expected);
});
}
expect(rendered).toContain('name="test&quot;&gt;&lt;file name=&quot;INJECTED&quot;"');
expect(rendered).toContain('before &lt;/file&gt; &lt;file name="evil"> after');
expect((rendered.match(/<\/file>/g) ?? []).length).toBe(1);
});
function expectRenderedContextCase(params: {
renderParams: Parameters<typeof renderFileContextBlock>[0];
expected?: string;
expectedSubstrings?: readonly string[];
expectedClosingTagCount?: number;
}) {
if (params.expected !== undefined) {
expect(renderFileContextBlock(params.renderParams)).toBe(params.expected);
return;
}
it("supports compact content mode for placeholder text", () => {
const rendered = renderFileContextBlock({
filename: 'pdf"><file name="INJECTED"',
content: "[PDF content rendered to images]",
surroundContentWithNewlines: false,
});
const rendered = renderFileContextBlock(params.renderParams);
expectRenderedContextContains(rendered, params.expectedSubstrings ?? []);
if (params.expectedClosingTagCount !== undefined) {
expect((rendered.match(/<\/file>/g) ?? []).length).toBe(params.expectedClosingTagCount);
}
}
expect(rendered).toBe(
'<file name="pdf&quot;&gt;&lt;file name=&quot;INJECTED&quot;">[PDF content rendered to images]</file>',
);
});
it("applies fallback filename and optional mime attributes", () => {
const rendered = renderFileContextBlock({
filename: " \n\t ",
fallbackName: "file-1",
mimeType: 'text/plain" bad',
content: "hello",
});
expect(rendered).toContain('<file name="file-1" mime="text/plain&quot; bad">');
expect(rendered).toContain("\nhello\n");
it.each([
{
name: "escapes filename attributes and file tag markers in content",
renderParams: {
filename: 'test"><file name="INJECTED"',
content: 'before </file> <file name="evil"> after',
},
expectedSubstrings: [
'name="test&quot;&gt;&lt;file name=&quot;INJECTED&quot;"',
'before &lt;/file&gt; &lt;file name="evil"> after',
],
expectedClosingTagCount: 1,
},
{
name: "supports compact content mode for placeholder text",
renderParams: {
filename: 'pdf"><file name="INJECTED"',
content: "[PDF content rendered to images]",
surroundContentWithNewlines: false,
},
expected:
'<file name="pdf&quot;&gt;&lt;file name=&quot;INJECTED&quot;">[PDF content rendered to images]</file>',
},
{
name: "applies fallback filename and optional mime attributes",
renderParams: {
filename: " \n\t ",
fallbackName: "file-1",
mimeType: 'text/plain" bad',
content: "hello",
},
expectedSubstrings: ['<file name="file-1" mime="text/plain&quot; bad">', "\nhello\n"],
},
] as const)("$name", (testCase) => {
expectRenderedContextCase(testCase);
});
});

View File

@@ -28,65 +28,119 @@ const { ensureMediaHosted } = await import("./host.js");
const { PortInUseError } = await import("../infra/ports.js");
describe("ensureMediaHosted", () => {
function mockSavedMedia(id: string, size: number) {
saveMediaSource.mockResolvedValue({
id,
path: `/tmp/${id}`,
size,
});
}
async function expectHostedMediaCase(
params:
| {
filePath: string;
savedMedia: { id: string; size: number };
tailnetHostname: string;
startServer: boolean;
expectedError: RegExp;
expectedCleanupPath: string;
}
| {
filePath: string;
savedMedia: { id: string; size: number };
tailnetHostname: string;
port: number;
startServer: boolean;
ensurePortError?: Error;
expectedUrl: string;
expectServerStart: boolean;
},
) {
getTailnetHostname.mockResolvedValue(params.tailnetHostname);
if ("expectedError" in params) {
saveMediaSource.mockResolvedValue({
id: params.savedMedia.id,
path: params.filePath,
size: params.savedMedia.size,
});
ensurePortAvailable.mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await expect(
ensureMediaHosted(params.filePath, { startServer: params.startServer }),
).rejects.toThrow(params.expectedError);
expect(rmSpy).toHaveBeenCalledWith(params.expectedCleanupPath);
rmSpy.mockRestore();
return;
}
mockSavedMedia(params.savedMedia.id, params.savedMedia.size);
if (params.ensurePortError) {
ensurePortAvailable.mockRejectedValue(params.ensurePortError);
} else {
ensurePortAvailable.mockResolvedValue(undefined);
startMediaServer.mockResolvedValue({ unref: vi.fn() } as unknown as Server);
}
const result = await ensureMediaHosted(params.filePath, {
startServer: params.startServer,
port: params.port,
});
if (params.expectServerStart) {
expect(startMediaServer).toHaveBeenCalledWith(
params.port,
expect.any(Number),
expect.anything(),
);
expect(logInfo).toHaveBeenCalled();
} else {
expect(startMediaServer).not.toHaveBeenCalled();
}
expect(result).toEqual({
url: params.expectedUrl,
id: params.savedMedia.id,
size: params.savedMedia.size,
});
}
beforeEach(() => {
vi.clearAllMocks();
});
it("throws and cleans up when server not allowed to start", async () => {
saveMediaSource.mockResolvedValue({
id: "id1",
path: "/tmp/file1",
size: 5,
});
getTailnetHostname.mockResolvedValue("tailnet-host");
ensurePortAvailable.mockResolvedValue(undefined);
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await expect(ensureMediaHosted("/tmp/file1", { startServer: false })).rejects.toThrow(
"requires the webhook/Funnel server",
);
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
rmSpy.mockRestore();
});
it("starts media server when allowed", async () => {
saveMediaSource.mockResolvedValue({
id: "id2",
path: "/tmp/file2",
size: 9,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockResolvedValue(undefined);
const fakeServer = { unref: vi.fn() } as unknown as Server;
startMediaServer.mockResolvedValue(fakeServer);
const result = await ensureMediaHosted("/tmp/file2", {
startServer: true,
port: 1234,
});
expect(startMediaServer).toHaveBeenCalledWith(1234, expect.any(Number), expect.anything());
expect(logInfo).toHaveBeenCalled();
expect(result).toEqual({
url: "https://tail.net/media/id2",
id: "id2",
size: 9,
});
});
it("skips server start when port already in use", async () => {
saveMediaSource.mockResolvedValue({
id: "id3",
path: "/tmp/file3",
size: 7,
});
getTailnetHostname.mockResolvedValue("tail.net");
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
const result = await ensureMediaHosted("/tmp/file3", {
it.each([
{
name: "throws and cleans up when server not allowed to start",
filePath: "/tmp/file1",
savedMedia: { id: "id1", size: 5 },
tailnetHostname: "tailnet-host",
startServer: false,
expectedError: /requires the webhook\/Funnel server/i,
expectedCleanupPath: "/tmp/file1",
},
{
name: "starts media server when allowed",
filePath: "/tmp/id2",
savedMedia: { id: "id2", size: 9 },
tailnetHostname: "tail.net",
port: 1234,
startServer: true,
expectedUrl: "https://tail.net/media/id2",
expectServerStart: true,
},
{
name: "skips server start when port already in use",
filePath: "/tmp/id3",
savedMedia: { id: "id3", size: 7 },
tailnetHostname: "tail.net",
port: 3000,
});
expect(startMediaServer).not.toHaveBeenCalled();
expect(result.url).toBe("https://tail.net/media/id3");
startServer: false,
ensurePortError: new PortInUseError(3000, "proc"),
expectedUrl: "https://tail.net/media/id3",
expectServerStart: false,
},
] as const)("$name", async (testCase) => {
await expectHostedMediaCase(testCase);
});
});

View File

@@ -2,17 +2,29 @@ import { describe, expect, it } from "vitest";
import { buildImageResizeSideGrid, IMAGE_REDUCE_QUALITY_STEPS } from "./image-ops.js";
describe("buildImageResizeSideGrid", () => {
it("returns descending unique sides capped by maxSide", () => {
expect(buildImageResizeSideGrid(1200, 900)).toEqual([1200, 1000, 900, 800]);
});
function expectImageResizeSideGridCase(width: number, height: number, expected: number[]) {
expect(buildImageResizeSideGrid(width, height)).toEqual(expected);
}
it("keeps only positive side values", () => {
expect(buildImageResizeSideGrid(0, 0)).toEqual([]);
it.each([
{ width: 1200, height: 900, expected: [1200, 1000, 900, 800] },
{ width: 0, height: 0, expected: [] },
] as const)("builds resize side grid for %ix%i", ({ width, height, expected }) => {
expectImageResizeSideGridCase(width, height, [...expected]);
});
});
describe("IMAGE_REDUCE_QUALITY_STEPS", () => {
it("keeps expected quality ladder", () => {
expect([...IMAGE_REDUCE_QUALITY_STEPS]).toEqual([85, 75, 65, 55, 45, 35]);
function expectQualityLadderCase(expectedQualityLadder: number[]) {
expect([...IMAGE_REDUCE_QUALITY_STEPS]).toEqual(expectedQualityLadder);
}
it.each([
{
name: "keeps expected quality ladder",
expectedQualityLadder: [85, 75, 65, 55, 45, 35],
},
] as const)("$name", ({ expectedQualityLadder }) => {
expectQualityLadderCase([...expectedQualityLadder]);
});
});

View File

@@ -10,88 +10,135 @@ import {
} from "./inbound-path-policy.js";
describe("inbound-path-policy", () => {
it("validates absolute root patterns", () => {
expect(isValidInboundPathRootPattern("/Users/*/Library/Messages/Attachments")).toBe(true);
expect(isValidInboundPathRootPattern("/Volumes/relay/attachments")).toBe(true);
expect(isValidInboundPathRootPattern("./attachments")).toBe(false);
expect(isValidInboundPathRootPattern("/Users/**/Attachments")).toBe(false);
});
function expectInboundRootPatternCase(pattern: string, expected: boolean) {
expect(isValidInboundPathRootPattern(pattern)).toBe(expected);
}
it("matches wildcard roots for iMessage attachment paths", () => {
const roots = ["/Users/*/Library/Messages/Attachments"];
function expectInboundPathAllowedCase(filePath: string, expected: boolean) {
expect(
isInboundPathAllowed({
filePath: "/Users/alice/Library/Messages/Attachments/12/34/ABCDEF/IMG_0001.jpeg",
roots,
}),
).toBe(true);
expect(
isInboundPathAllowed({
filePath: "/etc/passwd",
roots,
}),
).toBe(false);
isInboundPathAllowed({ filePath, roots: ["/Users/*/Library/Messages/Attachments"] }),
).toBe(expected);
}
function expectResolvedIMessageRootsCase(resolve: () => string[], expected: readonly string[]) {
expect(resolve()).toEqual(expected);
}
function expectMergedInboundPathRootsCase(params: {
defaults: string[];
additions: string[];
expected: readonly string[];
}) {
expect(mergeInboundPathRoots(params.defaults, params.additions)).toEqual(params.expected);
}
it.each([
{ pattern: "/Users/*/Library/Messages/Attachments", expected: true },
{ pattern: "/Volumes/relay/attachments", expected: true },
{ pattern: "./attachments", expected: false },
{ pattern: "/Users/**/Attachments", expected: false },
] as const)("validates absolute root pattern %s", ({ pattern, expected }) => {
expectInboundRootPatternCase(pattern, expected);
});
it("normalizes and de-duplicates merged roots", () => {
const roots = mergeInboundPathRoots(
["/Users/*/Library/Messages/Attachments/", "/Users/*/Library/Messages/Attachments"],
["/Volumes/relay/attachments"],
);
expect(roots).toEqual(["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"]);
it.each([
{
filePath: "/Users/alice/Library/Messages/Attachments/12/34/ABCDEF/IMG_0001.jpeg",
expected: true,
},
{
filePath: "/etc/passwd",
expected: false,
},
] as const)("matches wildcard roots for %s => $expected", ({ filePath, expected }) => {
expectInboundPathAllowedCase(filePath, expected);
});
it("resolves configured roots with account overrides", () => {
const cfg = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
accounts: {
work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/srv/work/attachments"],
},
const accountOverrideCfg = {
channels: {
imessage: {
attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/Volumes/shared/imessage"],
accounts: {
work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
remoteAttachmentRoots: ["/srv/work/attachments"],
},
},
},
} as OpenClawConfig;
expect(resolveIMessageAttachmentRoots({ cfg, accountId: "work" })).toEqual([
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
]);
expect(resolveIMessageRemoteAttachmentRoots({ cfg, accountId: "work" })).toEqual([
"/srv/work/attachments",
"/Volumes/shared/imessage",
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
]);
},
} as OpenClawConfig;
it.each([
{
name: "normalizes and de-duplicates merged roots",
run: () =>
expectMergedInboundPathRootsCase({
defaults: [
"/Users/*/Library/Messages/Attachments/",
"/Users/*/Library/Messages/Attachments",
],
additions: ["/Volumes/relay/attachments"],
expected: ["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"],
}),
},
{
name: "resolves configured attachment roots with account overrides",
run: () =>
expectResolvedIMessageRootsCase(
() => resolveIMessageAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
["/Users/work/Library/Messages/Attachments", "/Users/*/Library/Messages/Attachments"],
),
},
{
name: "resolves configured remote attachment roots with account overrides",
run: () =>
expectResolvedIMessageRootsCase(
() =>
resolveIMessageRemoteAttachmentRoots({ cfg: accountOverrideCfg, accountId: "work" }),
[
"/srv/work/attachments",
"/Volumes/shared/imessage",
"/Users/work/Library/Messages/Attachments",
"/Users/*/Library/Messages/Attachments",
],
),
},
] as const)("$name", ({ run }) => {
run();
});
it("matches iMessage account ids case-insensitively for attachment roots", () => {
const cfg = {
channels: {
imessage: {
accounts: {
Work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
it.each([
{
name: "matches iMessage account ids case-insensitively for attachment roots",
resolve: () => {
const cfg = {
channels: {
imessage: {
accounts: {
Work: {
attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
},
},
},
},
},
} as OpenClawConfig;
return resolveIMessageAttachmentRoots({ cfg, accountId: "work" });
},
} as OpenClawConfig;
expect(resolveIMessageAttachmentRoots({ cfg, accountId: "work" })).toEqual([
"/Users/work/Library/Messages/Attachments",
...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
]);
});
it("falls back to default iMessage roots", () => {
const cfg = {} as OpenClawConfig;
expect(resolveIMessageAttachmentRoots({ cfg })).toEqual([...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS]);
expect(resolveIMessageRemoteAttachmentRoots({ cfg })).toEqual([
...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
]);
expected: ["/Users/work/Library/Messages/Attachments", ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
{
name: "falls back to default iMessage attachment roots",
resolve: () => resolveIMessageAttachmentRoots({ cfg: {} as OpenClawConfig }),
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
{
name: "falls back to default iMessage remote attachment roots",
resolve: () => resolveIMessageRemoteAttachmentRoots({ cfg: {} as OpenClawConfig }),
expected: [...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS],
},
] as const)("$name", ({ resolve, expected }) => {
expectResolvedIMessageRootsCase(resolve, expected);
});
});

View File

@@ -33,149 +33,193 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe("HEIC input image normalization", () => {
it("converts base64 HEIC images to JPEG before returning them", async () => {
const normalized = Buffer.from("jpeg-normalized");
detectMimeMock.mockResolvedValueOnce("image/heic");
convertHeicToJpegMock.mockResolvedValueOnce(normalized);
function createImageSourceLimits(allowedMimes: string[], allowUrl = false) {
return {
allowUrl,
allowedMimes: new Set(allowedMimes),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: allowUrl ? 1000 : 1,
};
}
const image = await extractImageContentFromSource(
{
async function expectRejectedImageMimeCase(params: {
source: Parameters<typeof extractImageContentFromSource>[0];
limits: Parameters<typeof extractImageContentFromSource>[1];
expectedError: string;
fetchedUrl?: string;
fetchedContentType?: string;
fetchedBody?: Uint8Array;
}) {
const release = vi.fn(async () => {});
if (params.source.type === "url") {
const responseBody = Uint8Array.from(params.fetchedBody ?? Buffer.from("url-source"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
responseBody.buffer.slice(
responseBody.byteOffset,
responseBody.byteOffset + responseBody.byteLength,
),
{
status: 200,
headers: { "content-type": params.fetchedContentType ?? "application/octet-stream" },
},
),
release,
finalUrl: params.fetchedUrl ?? params.source.url,
});
}
await expect(extractImageContentFromSource(params.source, params.limits)).rejects.toThrow(
params.expectedError,
);
if (params.source.type === "url") {
expect(release).toHaveBeenCalledTimes(1);
}
}
type ImageSourceLimits = Parameters<typeof extractImageContentFromSource>[1];
async function expectResolvedImageContentCase(params: {
source: Parameters<typeof extractImageContentFromSource>[0];
limits: ImageSourceLimits;
detectedMime: string;
convertedBytes?: Buffer;
fetchedUrl?: string;
fetchedContentType?: string;
fetchedBody?: Uint8Array;
expectedImage: Awaited<ReturnType<typeof extractImageContentFromSource>>;
}) {
const release = vi.fn(async () => {});
if (params.source.type === "url") {
const responseBody = Uint8Array.from(params.fetchedBody ?? Buffer.from("url-source"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
responseBody.buffer.slice(
responseBody.byteOffset,
responseBody.byteOffset + responseBody.byteLength,
),
{
status: 200,
headers: { "content-type": params.fetchedContentType ?? "application/octet-stream" },
},
),
release,
finalUrl: params.fetchedUrl ?? params.source.url,
});
}
detectMimeMock.mockResolvedValueOnce(params.detectedMime);
if (params.convertedBytes) {
convertHeicToJpegMock.mockResolvedValueOnce(params.convertedBytes);
}
const image = await extractImageContentFromSource(params.source, params.limits);
expect(image).toEqual(params.expectedImage);
expect(detectMimeMock).toHaveBeenCalledTimes(1);
expect(convertHeicToJpegMock).toHaveBeenCalledTimes(params.convertedBytes ? 1 : 0);
if (params.source.type === "url") {
expect(release).toHaveBeenCalledTimes(1);
}
}
async function expectBase64ImageValidationCase(params: {
source: Parameters<typeof extractImageContentFromSource>[0];
limits: Parameters<typeof extractImageContentFromSource>[1];
expectedData?: string;
expectedError?: string;
}) {
if (params.expectedError) {
await expect(extractImageContentFromSource(params.source, params.limits)).rejects.toThrow(
params.expectedError,
);
return;
}
const image = await extractImageContentFromSource(params.source, params.limits);
expect(image.data).toBe(params.expectedData);
}
describe("HEIC input image normalization", () => {
it.each([
{
name: "converts base64 HEIC images to JPEG before returning them",
source: {
type: "base64",
data: Buffer.from("heic-source").toString("base64"),
mediaType: "image/heic",
} as const,
limits: createImageSourceLimits(["image/heic", "image/jpeg"]),
detectedMime: "image/heic",
convertedBytes: Buffer.from("jpeg-normalized"),
expectedImage: {
type: "image",
data: Buffer.from("jpeg-normalized").toString("base64"),
mimeType: "image/jpeg",
},
{
allowUrl: false,
allowedMimes: new Set(["image/heic", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
);
expect(convertHeicToJpegMock).toHaveBeenCalledTimes(1);
expect(image).toEqual({
type: "image",
data: normalized.toString("base64"),
mimeType: "image/jpeg",
});
});
it("converts URL HEIC images to JPEG before returning them", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(Buffer.from("heic-url-source"), {
status: 200,
headers: { "content-type": "image/heic" },
}),
release,
finalUrl: "https://example.com/photo.heic",
});
const normalized = Buffer.from("jpeg-url-normalized");
detectMimeMock.mockResolvedValueOnce("image/heic");
convertHeicToJpegMock.mockResolvedValueOnce(normalized);
const image = await extractImageContentFromSource(
{
},
{
name: "converts URL HEIC images to JPEG before returning them",
source: {
type: "url",
url: "https://example.com/photo.heic",
} as const,
limits: createImageSourceLimits(["image/heic", "image/jpeg"], true),
detectedMime: "image/heic",
convertedBytes: Buffer.from("jpeg-url-normalized"),
fetchedUrl: "https://example.com/photo.heic",
fetchedContentType: "image/heic",
fetchedBody: Buffer.from("heic-url-source"),
expectedImage: {
type: "image",
data: Buffer.from("jpeg-url-normalized").toString("base64"),
mimeType: "image/jpeg",
},
{
allowUrl: true,
allowedMimes: new Set(["image/heic", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1000,
},
);
expect(convertHeicToJpegMock).toHaveBeenCalledTimes(1);
expect(image).toEqual({
type: "image",
data: normalized.toString("base64"),
mimeType: "image/jpeg",
});
expect(release).toHaveBeenCalledTimes(1);
});
it("keeps declared MIME for non-HEIC images after validation", async () => {
detectMimeMock.mockResolvedValueOnce("image/png");
const image = await extractImageContentFromSource(
{
},
{
name: "keeps declared MIME for non-HEIC images after validation",
source: {
type: "base64",
data: Buffer.from("png-like").toString("base64"),
mediaType: "image/png",
} as const,
limits: createImageSourceLimits(["image/png"]),
detectedMime: "image/png",
expectedImage: {
type: "image",
data: Buffer.from("png-like").toString("base64"),
mimeType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
);
expect(detectMimeMock).toHaveBeenCalledTimes(1);
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
expect(image).toEqual({
type: "image",
data: Buffer.from("png-like").toString("base64"),
mimeType: "image/png",
});
},
] as const)("$name", async (testCase) => {
await expectResolvedImageContentCase(testCase);
});
it("rejects spoofed base64 images when detected bytes are not an image", async () => {
it.each([
{
name: "rejects spoofed base64 images when detected bytes are not an image",
source: {
type: "base64" as const,
data: Buffer.from("%PDF-1.4\n").toString("base64"),
mediaType: "image/png",
},
limits: createImageSourceLimits(["image/png", "image/jpeg"]),
expectedError: "Unsupported image MIME type: application/pdf",
},
{
name: "rejects spoofed URL images when detected bytes are not an image",
source: {
type: "url" as const,
url: "https://example.com/photo.png",
},
limits: createImageSourceLimits(["image/png", "image/jpeg"], true),
expectedError: "Unsupported image MIME type: application/pdf",
fetchedUrl: "https://example.com/photo.png",
fetchedContentType: "image/png",
fetchedBody: Buffer.from("%PDF-1.4\n"),
},
] as const)("$name", async (testCase) => {
detectMimeMock.mockResolvedValueOnce("application/pdf");
await expect(
extractImageContentFromSource(
{
type: "base64",
data: Buffer.from("%PDF-1.4\n").toString("base64"),
mediaType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
),
).rejects.toThrow("Unsupported image MIME type: application/pdf");
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
});
it("rejects spoofed URL images when detected bytes are not an image", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(Buffer.from("%PDF-1.4\n"), {
status: 200,
headers: { "content-type": "image/png" },
}),
release,
finalUrl: "https://example.com/photo.png",
});
detectMimeMock.mockResolvedValueOnce("application/pdf");
await expect(
extractImageContentFromSource(
{
type: "url",
url: "https://example.com/photo.png",
},
{
allowUrl: true,
allowedMimes: new Set(["image/png", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1000,
},
),
).rejects.toThrow("Unsupported image MIME type: application/pdf");
expect(release).toHaveBeenCalledTimes(1);
await expectRejectedImageMimeCase(testCase);
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
});
});
@@ -276,40 +320,39 @@ describe("base64 size guards", () => {
});
describe("input image base64 validation", () => {
it("rejects malformed base64 payloads", async () => {
await expect(
extractImageContentFromSource(
{
type: "base64",
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
mediaType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
),
).rejects.toThrow("invalid 'data' field");
});
it("normalizes whitespace in valid base64 payloads", async () => {
const image = await extractImageContentFromSource(
{
it.each([
{
name: "rejects malformed base64 payloads",
source: {
type: "base64",
data: " aGVs bG8= \n",
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)',
mediaType: "image/png",
},
{
} as const,
limits: {
allowUrl: false,
allowedMimes: new Set(["image/png"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
);
expect(image.data).toBe("aGVsbG8=");
expectedError: "invalid 'data' field",
},
{
name: "normalizes whitespace in valid base64 payloads",
source: {
type: "base64",
data: " aGVs bG8= \n",
mediaType: "image/png",
} as const,
limits: createImageSourceLimits(["image/png"]),
expectedData: "aGVsbG8=",
},
] as const)("$name", async ({ source, limits, expectedData, expectedError }) => {
await expectBase64ImageValidationCase({
source,
limits,
...(expectedData ? { expectedData } : {}),
...(expectedError ? { expectedError } : {}),
});
});
});

View File

@@ -2,24 +2,38 @@ import { describe, expect, it } from "vitest";
import { buildOutboundMediaLoadOptions, resolveOutboundMediaLocalRoots } from "./load-options.js";
describe("media load options", () => {
it("returns undefined localRoots when mediaLocalRoots is empty", () => {
expect(resolveOutboundMediaLocalRoots(undefined)).toBeUndefined();
expect(resolveOutboundMediaLocalRoots([])).toBeUndefined();
function expectResolvedOutboundMediaRoots(
mediaLocalRoots: readonly string[] | undefined,
expectedLocalRoots: readonly string[] | undefined,
) {
expect(resolveOutboundMediaLocalRoots(mediaLocalRoots)).toEqual(expectedLocalRoots);
}
function expectBuiltOutboundMediaLoadOptions(
params: Parameters<typeof buildOutboundMediaLoadOptions>[0],
expected: ReturnType<typeof buildOutboundMediaLoadOptions>,
) {
expect(buildOutboundMediaLoadOptions(params)).toEqual(expected);
}
it.each([
{ mediaLocalRoots: undefined, expectedLocalRoots: undefined },
{ mediaLocalRoots: [], expectedLocalRoots: undefined },
{ mediaLocalRoots: ["/tmp/workspace"], expectedLocalRoots: ["/tmp/workspace"] },
] as const)("resolves outbound local roots %#", ({ mediaLocalRoots, expectedLocalRoots }) => {
expectResolvedOutboundMediaRoots(mediaLocalRoots, expectedLocalRoots);
});
it("keeps trusted mediaLocalRoots entries", () => {
expect(resolveOutboundMediaLocalRoots(["/tmp/workspace"])).toEqual(["/tmp/workspace"]);
});
it("builds loadWebMedia options from maxBytes and mediaLocalRoots", () => {
expect(
buildOutboundMediaLoadOptions({
maxBytes: 1024,
mediaLocalRoots: ["/tmp/workspace"],
}),
).toEqual({
maxBytes: 1024,
localRoots: ["/tmp/workspace"],
});
it.each([
{
params: { maxBytes: 1024, mediaLocalRoots: ["/tmp/workspace"] },
expected: { maxBytes: 1024, localRoots: ["/tmp/workspace"] },
},
{
params: { maxBytes: 2048, mediaLocalRoots: undefined },
expected: { maxBytes: 2048, localRoots: undefined },
},
] as const)("builds outbound media load options %#", ({ params, expected }) => {
expectBuiltOutboundMediaLoadOptions(params, expected);
});
});

View File

@@ -13,34 +13,92 @@ function normalizeHostPath(value: string): string {
}
describe("local media roots", () => {
function withStateDir<T>(stateDir: string, run: () => T): T {
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
return run();
}
function expectNormalizedRootsContain(
roots: readonly string[],
expectedRoots: readonly string[],
) {
const normalizedRoots = roots.map(normalizeHostPath);
expectedRoots.forEach((expectedRoot) => {
expect(normalizedRoots).toContain(normalizeHostPath(expectedRoot));
});
}
function expectNormalizedRootsExclude(
roots: readonly string[],
excludedRoots: readonly string[],
) {
const normalizedRoots = roots.map(normalizeHostPath);
excludedRoots.forEach((excludedRoot) => {
expect(normalizedRoots).not.toContain(normalizeHostPath(excludedRoot));
});
}
function expectPicturesRootPresence(params: {
roots: readonly string[];
shouldContainPictures: boolean;
picturesRoot?: string;
}) {
const normalizedRoots = params.roots.map(normalizeHostPath);
const picturesRoot = normalizeHostPath(params.picturesRoot ?? "/Users/peter/Pictures");
if (params.shouldContainPictures) {
expect(normalizedRoots).toContain(picturesRoot);
return;
}
expect(normalizedRoots).not.toContain(picturesRoot);
}
function expectAgentMediaRootsCase(params: {
stateDir: string;
getRoots: () => readonly string[];
expectedContained?: readonly string[];
expectedExcluded?: readonly string[];
minLength?: number;
}) {
const roots = withStateDir(params.stateDir, params.getRoots);
if (params.expectedContained) {
expectNormalizedRootsContain(roots, params.expectedContained);
}
if (params.expectedExcluded) {
expectNormalizedRootsExclude(roots, params.expectedExcluded);
}
if (params.minLength !== undefined) {
expect(roots.length).toBeGreaterThanOrEqual(params.minLength);
}
}
afterEach(() => {
vi.unstubAllEnvs();
});
it("keeps temp, media cache, and workspace roots by default", () => {
const stateDir = path.join("/tmp", "openclaw-media-roots-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const roots = getDefaultMediaLocalRoots();
const normalizedRoots = roots.map(normalizeHostPath);
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "media")));
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace")));
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "sandboxes")));
expect(normalizedRoots).not.toContain(normalizeHostPath(path.join(stateDir, "agents")));
expect(roots.length).toBeGreaterThanOrEqual(3);
});
it("adds the active agent workspace without re-opening broad agent state roots", () => {
const stateDir = path.join("/tmp", "openclaw-agent-media-roots-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const roots = getAgentScopedMediaLocalRoots({}, "ops");
const normalizedRoots = roots.map(normalizeHostPath);
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace-ops")));
expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "sandboxes")));
expect(normalizedRoots).not.toContain(normalizeHostPath(path.join(stateDir, "agents")));
it.each([
{
name: "keeps temp, media cache, and workspace roots by default",
stateDir: path.join("/tmp", "openclaw-media-roots-state"),
getRoots: () => getDefaultMediaLocalRoots(),
expectedContained: ["media", "workspace", "sandboxes"],
expectedExcluded: ["agents"],
minLength: 3,
},
{
name: "adds the active agent workspace without re-opening broad agent state roots",
stateDir: path.join("/tmp", "openclaw-agent-media-roots-state"),
getRoots: () => getAgentScopedMediaLocalRoots({}, "ops"),
expectedContained: ["workspace-ops", "sandboxes"],
expectedExcluded: ["agents"],
},
] as const)("$name", ({ stateDir, getRoots, expectedContained, expectedExcluded, minLength }) => {
expectAgentMediaRootsCase({
stateDir,
getRoots,
expectedContained: expectedContained.map((suffix) => path.join(stateDir, suffix)),
expectedExcluded: expectedExcluded.map((suffix) => path.join(stateDir, suffix)),
minLength,
});
});
it("adds concrete parent roots for local media sources without widening to filesystem root", () => {
@@ -69,57 +127,44 @@ describe("local media roots", () => {
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
});
it("widens agent media roots for concrete local sources only when workspaceOnly is disabled", () => {
const stateDir = path.join("/tmp", "openclaw-flexible-media-roots-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const flexibleRoots = getAgentScopedMediaLocalRootsForSources({
it.each([
{
name: "widens agent media roots for concrete local sources when workspaceOnly is disabled",
stateDir: path.join("/tmp", "openclaw-flexible-media-roots-state"),
cfg: {},
agentId: "ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
});
expect(flexibleRoots.map(normalizeHostPath)).toContain(
normalizeHostPath("/Users/peter/Pictures"),
);
const strictRoots = getAgentScopedMediaLocalRootsForSources({
shouldContainPictures: true,
},
{
name: "does not widen agent media roots when workspaceOnly is enabled",
stateDir: path.join("/tmp", "openclaw-flexible-media-roots-state"),
cfg: { tools: { fs: { workspaceOnly: true } } },
agentId: "ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
});
expect(strictRoots.map(normalizeHostPath)).not.toContain(
normalizeHostPath("/Users/peter/Pictures"),
);
});
it("does not widen media roots for messaging-profile agents without filesystem tools", () => {
const stateDir = path.join("/tmp", "openclaw-messaging-media-roots-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const roots = getAgentScopedMediaLocalRootsForSources({
shouldContainPictures: false,
},
{
name: "does not widen media roots for messaging-profile agents without filesystem tools",
stateDir: path.join("/tmp", "openclaw-messaging-media-roots-state"),
cfg: { tools: { profile: "messaging" } },
agentId: "ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
});
expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/Users/peter/Pictures"));
});
it("widens media roots again when messaging-profile agents explicitly enable filesystem tools", () => {
const stateDir = path.join("/tmp", "openclaw-messaging-fs-media-roots-state");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const roots = getAgentScopedMediaLocalRootsForSources({
shouldContainPictures: false,
},
{
name: "widens media roots again when messaging-profile agents explicitly enable filesystem tools",
stateDir: path.join("/tmp", "openclaw-messaging-fs-media-roots-state"),
cfg: {
tools: {
profile: "messaging",
fs: { workspaceOnly: false },
},
},
agentId: "ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
});
expect(roots.map(normalizeHostPath)).toContain(normalizeHostPath("/Users/peter/Pictures"));
shouldContainPictures: true,
},
] as const)("$name", ({ stateDir, cfg, shouldContainPictures }) => {
const roots = withStateDir(stateDir, () =>
getAgentScopedMediaLocalRootsForSources({
cfg,
agentId: "ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
}),
);
expectPicturesRootPresence({ roots, shouldContainPictures });
});
});

View File

@@ -21,6 +21,13 @@ async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promi
}
describe("mime detection", () => {
async function expectDetectedMime(params: {
input: Parameters<typeof detectMime>[0];
expected: string;
}) {
expect(await detectMime(params.input)).toBe(params.expected);
}
it.each([
{ format: "jpg", expected: "image/jpeg" },
{ format: "jpeg", expected: "image/jpeg" },
@@ -32,45 +39,65 @@ describe("mime detection", () => {
expect(imageMimeFromFormat(format)).toBe(expected);
});
it("detects docx from buffer", async () => {
const buf = await makeOoxmlZip({
it.each([
{
name: "detects docx from buffer",
mainMime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
partPath: "/word/document.xml",
});
const mime = await detectMime({ buffer: buf, filePath: "/tmp/file.bin" });
expect(mime).toBe("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
});
it("detects pptx from buffer", async () => {
const buf = await makeOoxmlZip({
expected: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
name: "detects pptx from buffer",
mainMime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
partPath: "/ppt/presentation.xml",
expected: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
] as const)("$name", async ({ mainMime, partPath, expected }) => {
await expectDetectedMime({
input: {
buffer: await makeOoxmlZip({ mainMime, partPath }),
filePath: "/tmp/file.bin",
},
expected,
});
const mime = await detectMime({ buffer: buf, filePath: "/tmp/file.bin" });
expect(mime).toBe("application/vnd.openxmlformats-officedocument.presentationml.presentation");
});
it("prefers extension mapping over generic zip", async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
const buf = await zip.generateAsync({ type: "nodebuffer" });
const mime = await detectMime({
buffer: buf,
filePath: "/tmp/file.xlsx",
it.each([
{
name: "prefers extension mapping over generic zip",
input: async () => {
const zip = new JSZip();
zip.file("hello.txt", "hi");
return {
buffer: await zip.generateAsync({ type: "nodebuffer" }),
filePath: "/tmp/file.xlsx",
};
},
expected: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
name: "uses extension mapping for JavaScript assets",
input: async () => ({
filePath: "/tmp/a2ui.bundle.js",
}),
expected: "text/javascript",
},
] as const)("$name", async ({ input, expected }) => {
await expectDetectedMime({
input: await input(),
expected,
});
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
});
it("uses extension mapping for JavaScript assets", async () => {
const mime = await detectMime({
filePath: "/tmp/a2ui.bundle.js",
});
expect(mime).toBe("text/javascript");
});
});
describe("extensionForMime", () => {
function expectMimeExtensionCase(
mime: Parameters<typeof extensionForMime>[0],
expected: ReturnType<typeof extensionForMime>,
) {
expect(extensionForMime(mime)).toBe(expected);
}
it.each([
{ mime: "image/jpeg", expected: ".jpg" },
{ mime: "image/png", expected: ".png" },
@@ -94,32 +121,57 @@ describe("extensionForMime", () => {
{ mime: null, expected: undefined },
{ mime: undefined, expected: undefined },
] as const)("maps $mime to extension", ({ mime, expected }) => {
expect(extensionForMime(mime)).toBe(expected);
expectMimeExtensionCase(mime, expected);
});
});
describe("isAudioFileName", () => {
function expectAudioFileNameCase(fileName: string, expected: boolean) {
expect(isAudioFileName(fileName)).toBe(expected);
}
it.each([
{ fileName: "voice.mp3", expected: true },
{ fileName: "voice.caf", expected: true },
{ fileName: "voice.bin", expected: false },
] as const)("matches audio extension for $fileName", ({ fileName, expected }) => {
expect(isAudioFileName(fileName)).toBe(expected);
expectAudioFileNameCase(fileName, expected);
});
});
describe("normalizeMimeType", () => {
function expectNormalizedMimeCase(
input: Parameters<typeof normalizeMimeType>[0],
expected: ReturnType<typeof normalizeMimeType>,
) {
expect(normalizeMimeType(input)).toBe(expected);
}
it.each([
{ input: "Audio/MP4; codecs=mp4a.40.2", expected: "audio/mp4" },
{ input: " ", expected: undefined },
{ input: null, expected: undefined },
{ input: undefined, expected: undefined },
] as const)("normalizes $input", ({ input, expected }) => {
expect(normalizeMimeType(input)).toBe(expected);
expectNormalizedMimeCase(input, expected);
});
});
describe("mediaKindFromMime", () => {
function expectMediaKindCase(
mime: Parameters<typeof mediaKindFromMime>[0],
expected: ReturnType<typeof mediaKindFromMime>,
) {
expect(mediaKindFromMime(mime)).toBe(expected);
}
function expectMimeKindCase(
mime: Parameters<typeof kindFromMime>[0],
expected: ReturnType<typeof kindFromMime>,
) {
expect(kindFromMime(mime)).toBe(expected);
}
it.each([
{ mime: "text/plain", expected: "document" },
{ mime: "text/csv", expected: "document" },
@@ -128,15 +180,14 @@ describe("mediaKindFromMime", () => {
{ mime: null, expected: undefined },
{ mime: undefined, expected: undefined },
] as const)("classifies $mime", ({ mime, expected }) => {
expect(mediaKindFromMime(mime)).toBe(expected);
expectMediaKindCase(mime, expected);
});
it("normalizes MIME strings before kind classification", () => {
expect(kindFromMime(" Audio/Ogg; codecs=opus ")).toBe("audio");
});
it("returns undefined for missing or unrecognized MIME kinds", () => {
expect(kindFromMime(undefined)).toBeUndefined();
expect(kindFromMime("model/gltf+json")).toBeUndefined();
it.each([
{ mime: " Audio/Ogg; codecs=opus ", expected: "audio" },
{ mime: undefined, expected: undefined },
{ mime: "model/gltf+json", expected: undefined },
] as const)("maps kindFromMime($mime) => $expected", ({ mime, expected }) => {
expectMimeKindCase(mime, expected);
});
});

View File

@@ -2,11 +2,36 @@ import { describe, expect, it } from "vitest";
import { splitMediaFromOutput } from "./parse.js";
describe("splitMediaFromOutput", () => {
it("detects audio_as_voice tag and strips it", () => {
const result = splitMediaFromOutput("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("Hello world");
});
function expectParsedMediaOutputCase(
input: string,
expected: {
mediaUrls?: string[];
text?: string;
audioAsVoice?: boolean;
},
) {
expect(splitMediaFromOutput(input)).toEqual(
expect.objectContaining({
text: "",
audioAsVoice: false,
...expected,
}),
);
}
function expectStableAudioAsVoiceDetectionCase(input: string) {
for (const output of [splitMediaFromOutput(input), splitMediaFromOutput(input)]) {
expect(output.audioAsVoice).toBe(true);
}
}
function expectAcceptedMediaPathCase(expectedPath: string, input: string) {
expectParsedMediaOutputCase(input, { mediaUrls: [expectedPath] });
}
function expectRejectedMediaPathCase(input: string) {
expectParsedMediaOutputCase(input, { mediaUrls: undefined });
}
it.each([
["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"],
@@ -18,9 +43,7 @@ describe("splitMediaFromOutput", () => {
["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"],
["image.png", "MEDIA:image.png"],
] as const)("accepts supported media path variant: %s", (expectedPath, input) => {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toEqual([expectedPath]);
expect(result.text).toBe("");
expectAcceptedMediaPathCase(expectedPath, input);
});
it.each([
@@ -30,28 +53,35 @@ describe("splitMediaFromOutput", () => {
"MEDIA:~/Pictures/My File.png",
"MEDIA:./foo/../../../etc/shadow",
] as const)("rejects traversal and home-dir path: %s", (input) => {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toBeUndefined();
expect(result.text).toBe("");
expectRejectedMediaPathCase(input);
});
it("keeps audio_as_voice detection stable across calls", () => {
const input = "Hello [[audio_as_voice]]";
const first = splitMediaFromOutput(input);
const second = splitMediaFromOutput(input);
expect(first.audioAsVoice).toBe(true);
expect(second.audioAsVoice).toBe(true);
});
it("keeps MEDIA mentions in prose", () => {
const input = "The MEDIA: tag fails to deliver";
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toBeUndefined();
expect(result.text).toBe(input);
});
it("rejects bare words without file extensions", () => {
const result = splitMediaFromOutput("MEDIA:screenshot");
expect(result.mediaUrls).toBeUndefined();
it.each([
{
name: "detects audio_as_voice tag and strips it",
input: "Hello [[audio_as_voice]] world",
expected: { audioAsVoice: true, text: "Hello world" },
},
{
name: "keeps MEDIA mentions in prose",
input: "The MEDIA: tag fails to deliver",
expected: { mediaUrls: undefined, text: "The MEDIA: tag fails to deliver" },
},
{
name: "rejects bare words without file extensions",
input: "MEDIA:screenshot",
expected: { mediaUrls: undefined },
},
{
name: "keeps audio_as_voice detection stable across calls",
input: "Hello [[audio_as_voice]]",
expected: { audioAsVoice: true, text: "Hello" },
assertStable: true,
},
] as const)("$name", ({ input, expected, assertStable }) => {
expectParsedMediaOutputCase(input, expected);
if (assertStable) {
expectStableAudioAsVoiceDetectionCase(input);
}
});
});

View File

@@ -25,66 +25,126 @@ function makeStallingStream(initialChunks: Uint8Array[]) {
});
}
async function expectIdleTimeout(
createReadPromise: () => Promise<unknown>,
expectedError: RegExp | string = /stalled/i,
) {
vi.useFakeTimers();
try {
const rejection = expect(createReadPromise()).rejects.toThrow(expectedError);
await vi.advanceTimersByTimeAsync(60);
await rejection;
} finally {
vi.useRealTimers();
}
}
async function expectReadResponseTextSnippetCase(params: {
response: Response;
options: Parameters<typeof readResponseTextSnippet>[1];
expected: string;
}) {
await expect(readResponseTextSnippet(params.response, params.options)).resolves.toBe(
params.expected,
);
}
async function expectReadResponseWithLimitSuccessCase(params: {
response: Response;
maxBytes: number;
expected: Buffer;
options?: Parameters<typeof readResponseWithLimit>[2];
}) {
const buf = await readResponseWithLimit(params.response, params.maxBytes, params.options);
expect(buf).toEqual(params.expected);
}
async function expectReadResponseWithLimitFailureCase(params: {
response: Response;
maxBytes: number;
options?: Parameters<typeof readResponseWithLimit>[2];
expectedError: RegExp | string;
}) {
await expect(
readResponseWithLimit(params.response, params.maxBytes, params.options),
).rejects.toThrow(params.expectedError);
}
describe("readResponseWithLimit", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("reads all chunks within the limit", async () => {
const body = makeStream([new Uint8Array([1, 2]), new Uint8Array([3, 4])]);
const res = new Response(body);
const buf = await readResponseWithLimit(res, 100);
expect(buf).toEqual(Buffer.from([1, 2, 3, 4]));
});
it("throws when total exceeds maxBytes", async () => {
const body = makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]);
const res = new Response(body);
await expect(readResponseWithLimit(res, 4)).rejects.toThrow(/too large/i);
});
it("calls custom onOverflow", async () => {
const body = makeStream([new Uint8Array(10)]);
const res = new Response(body);
await expect(
readResponseWithLimit(res, 5, {
onOverflow: ({ size, maxBytes }) => new Error(`custom: ${size} > ${maxBytes}`),
}),
).rejects.toThrow("custom: 10 > 5");
});
it("times out when no new chunk arrives before idle timeout", async () => {
vi.useFakeTimers();
try {
const body = makeStallingStream([new Uint8Array([1, 2])]);
const res = new Response(body);
const readPromise = readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 });
const rejection = expect(readPromise).rejects.toThrow(/stalled/i);
await vi.advanceTimersByTimeAsync(60);
await rejection;
} finally {
vi.useRealTimers();
it.each([
{
name: "reads all chunks within the limit",
response: new Response(makeStream([new Uint8Array([1, 2]), new Uint8Array([3, 4])])),
maxBytes: 100,
expected: Buffer.from([1, 2, 3, 4]),
},
{
name: "throws when total exceeds maxBytes",
response: new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])])),
maxBytes: 4,
expectedError: /too large/i,
},
{
name: "calls custom onOverflow",
response: new Response(makeStream([new Uint8Array(10)])),
maxBytes: 5,
options: {
onOverflow: ({ size, maxBytes: localMaxBytes }: { size: number; maxBytes: number }) =>
new Error(`custom: ${size} > ${localMaxBytes}`),
},
expectedError: "custom: 10 > 5",
},
] as const)("$name", async ({ response, maxBytes, options, expected, expectedError }) => {
if (expected !== undefined) {
await expectReadResponseWithLimitSuccessCase({ response, maxBytes, options, expected });
return;
}
}, 5_000);
it("uses a custom idle-timeout error when provided", async () => {
vi.useFakeTimers();
try {
const body = makeStallingStream([new Uint8Array([1, 2])]);
const res = new Response(body);
const readPromise = readResponseWithLimit(res, 1024, {
await expectReadResponseWithLimitFailureCase({
response,
maxBytes,
options,
expectedError,
});
});
it.each([
{
name: "times out when no new chunk arrives before idle timeout",
expectedError: /stalled/i,
options: { chunkTimeoutMs: 50 },
},
{
name: "uses a custom idle-timeout error when provided",
expectedError: "custom idle 50",
options: {
chunkTimeoutMs: 50,
onIdleTimeout: ({ chunkTimeoutMs }) => new Error(`custom idle ${chunkTimeoutMs}`),
});
const rejection = expect(readPromise).rejects.toThrow("custom idle 50");
await vi.advanceTimersByTimeAsync(60);
await rejection;
} finally {
vi.useRealTimers();
}
}, 5_000);
onIdleTimeout: ({ chunkTimeoutMs }: { chunkTimeoutMs: number }) =>
new Error(`custom idle ${chunkTimeoutMs}`),
},
},
] as const)(
"$name",
async ({ expectedError, options }) => {
await expectIdleTimeout(() => {
const body = makeStallingStream([new Uint8Array([1, 2])]);
const res = new Response(body);
return readResponseWithLimit(res, 1024, options);
}, expectedError);
},
5_000,
);
it("does not time out while chunks keep arriving", async () => {
it.each([
{
name: "does not time out while chunks keep arriving",
expected: Buffer.from([1, 2]),
},
] as const)("$name", async ({ expected }) => {
vi.useFakeTimers();
try {
const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10);
@@ -92,7 +152,7 @@ describe("readResponseWithLimit", () => {
const readPromise = readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 });
await vi.advanceTimersByTimeAsync(25);
const buf = await readPromise;
expect(buf).toEqual(Buffer.from([1, 2]));
expect(buf).toEqual(expected);
} finally {
vi.useRealTimers();
}
@@ -104,32 +164,38 @@ describe("readResponseTextSnippet", () => {
vi.useRealTimers();
});
it("returns collapsed text within the limit", async () => {
const res = new Response(makeStream([new TextEncoder().encode("hello \n world")]));
await expect(readResponseTextSnippet(res, { maxBytes: 64, maxChars: 50 })).resolves.toBe(
"hello world",
);
it.each([
{
name: "returns collapsed text within the limit",
response: new Response(makeStream([new TextEncoder().encode("hello \n world")])),
options: { maxBytes: 64, maxChars: 50 },
expected: "hello world",
},
{
name: "truncates to the byte limit without reading the full body",
response: new Response(
makeStream([new TextEncoder().encode("12345"), new TextEncoder().encode("67890")]),
),
options: { maxBytes: 7, maxChars: 50 },
expected: "1234567…",
},
] as const)("$name", async ({ response, options, expected }) => {
await expectReadResponseTextSnippetCase({ response, options, expected });
});
it("truncates to the byte limit without reading the full body", async () => {
const res = new Response(
makeStream([new TextEncoder().encode("12345"), new TextEncoder().encode("67890")]),
);
await expect(readResponseTextSnippet(res, { maxBytes: 7, maxChars: 50 })).resolves.toBe(
"1234567…",
);
});
it("applies the idle timeout while reading snippets", async () => {
vi.useFakeTimers();
try {
const res = new Response(makeStallingStream([new Uint8Array([65, 66])]));
const readPromise = readResponseTextSnippet(res, { maxBytes: 64, chunkTimeoutMs: 50 });
const rejection = expect(readPromise).rejects.toThrow(/stalled/i);
await vi.advanceTimersByTimeAsync(60);
await rejection;
} finally {
vi.useRealTimers();
}
}, 5_000);
it.each([
{
name: "applies the idle timeout while reading snippets",
createReadPromise: () => {
const res = new Response(makeStallingStream([new Uint8Array([65, 66])]));
return readResponseTextSnippet(res, { maxBytes: 64, chunkTimeoutMs: 50 });
},
},
] as const)(
"$name",
async ({ createReadPromise }) => {
await expectIdleTimeout(createReadPromise);
},
5_000,
);
});

View File

@@ -33,6 +33,12 @@ let SafeOpenError: typeof import("../infra/fs-safe.js").SafeOpenError;
let startMediaServer: typeof import("./server.js").startMediaServer;
let realFetch: typeof import("undici").fetch;
async function expectOutsideWorkspaceServerResponse(url: string) {
const response = await realFetch(url);
expect(response.status).toBe(400);
expect(await response.text()).toBe("file is outside workspace root");
}
describe("media server outside-workspace mapping", () => {
let server: Awaited<ReturnType<typeof startMediaServer>>;
let port = 0;
@@ -66,8 +72,6 @@ describe("media server outside-workspace mapping", () => {
new SafeOpenError("outside-workspace", "file is outside workspace root"),
);
const response = await realFetch(`http://127.0.0.1:${port}/media/ok-id`);
expect(response.status).toBe(400);
expect(await response.text()).toBe("file is outside workspace root");
await expectOutsideWorkspaceServerResponse(`http://127.0.0.1:${port}/media/ok-id`);
});
});

View File

@@ -47,6 +47,61 @@ describe("media server", () => {
return filePath;
}
async function ageMediaFile(filePath: string) {
const past = Date.now() - 10_000;
await fs.utimes(filePath, past / 1000, past / 1000);
}
async function expectMissingMediaFile(filePath: string) {
await expect(fs.stat(filePath)).rejects.toThrow();
}
function expectFetchedResponse(
response: Awaited<ReturnType<typeof realFetch>>,
expected: { status: number; noSniff?: boolean },
) {
expect(response.status).toBe(expected.status);
if (expected.noSniff) {
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
}
}
async function expectMediaFileLifecycleCase(params: {
id: string;
contents: string;
expectedStatus: number;
expectedBody?: string;
mutateFile?: (filePath: string) => Promise<void>;
assertAfterFetch?: (filePath: string) => Promise<void>;
}) {
const file = await writeMediaFile(params.id, params.contents);
await params.mutateFile?.(file);
const res = await realFetch(mediaUrl(params.id));
expectFetchedResponse(res, { status: params.expectedStatus });
if (params.expectedBody !== undefined) {
expect(await res.text()).toBe(params.expectedBody);
}
await params.assertAfterFetch?.(file);
}
async function expectFetchedMediaCase(params: {
mediaPath: string;
expectedStatus: number;
expectedBody?: string;
expectedNoSniff?: boolean;
setup?: () => Promise<void>;
}) {
await params.setup?.();
const res = await realFetch(mediaUrl(params.mediaPath));
expectFetchedResponse(res, {
status: params.expectedStatus,
...(params.expectedNoSniff ? { noSniff: true } : {}),
});
if (params.expectedBody !== undefined) {
expect(await res.text()).toBe(params.expectedBody);
}
}
beforeAll(async () => {
vi.useRealTimers();
vi.doUnmock("undici");
@@ -66,32 +121,41 @@ describe("media server", () => {
MEDIA_DIR = "";
});
it("serves media and cleans up after send", async () => {
const file = await writeMediaFile("file1", "hello");
const res = await realFetch(mediaUrl("file1"));
expect(res.status).toBe(200);
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(await res.text()).toBe("hello");
await waitForFileRemoval(file);
});
it("expires old media", async () => {
const file = await writeMediaFile("old", "stale");
const past = Date.now() - 10_000;
await fs.utimes(file, past / 1000, past / 1000);
const res = await realFetch(mediaUrl("old"));
expect(res.status).toBe(410);
await expect(fs.stat(file)).rejects.toThrow();
it.each([
{
name: "serves media and cleans up after send",
id: "file1",
contents: "hello",
expectedStatus: 200,
expectedBody: "hello",
assertAfterFetch: async (filePath: string) => {
await waitForFileRemoval(filePath);
},
},
{
name: "expires old media",
id: "old",
contents: "stale",
expectedStatus: 410,
mutateFile: ageMediaFile,
assertAfterFetch: expectMissingMediaFile,
},
] as const)("$name", async (testCase) => {
await expectMediaFileLifecycleCase(testCase);
});
it.each([
{
testName: "blocks path traversal attempts",
mediaPath: "%2e%2e%2fpackage.json",
expectedStatus: 400,
expectedBody: "invalid path",
},
{
testName: "rejects invalid media ids",
mediaPath: "invalid%20id",
expectedStatus: 400,
expectedBody: "invalid path",
setup: async () => {
await writeMediaFile("file2", "hello");
},
@@ -104,37 +168,38 @@ describe("media server", () => {
const link = path.join(MEDIA_DIR, "link-out");
await fs.symlink(target, link);
},
expectedStatus: 400,
expectedBody: "invalid path",
},
] as const)("$testName", async (testCase) => {
await testCase.setup?.();
const res = await realFetch(mediaUrl(testCase.mediaPath));
expect(res.status).toBe(400);
expect(await res.text()).toBe("invalid path");
});
it("rejects oversized media files", async () => {
const file = await writeMediaFile("big", "");
await fs.truncate(file, MEDIA_MAX_BYTES + 1);
const res = await realFetch(mediaUrl("big"));
expect(res.status).toBe(413);
expect(await res.text()).toBe("too large");
});
it("returns not found for missing media IDs", async () => {
const res = await realFetch(mediaUrl("missing-file"));
expect(res.status).toBe(404);
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(await res.text()).toBe("not found");
});
it("returns 404 when route param is missing (dot path)", async () => {
const res = await realFetch(mediaUrl("."));
expect(res.status).toBe(404);
});
it("rejects overlong media id", async () => {
const res = await realFetch(mediaUrl(`${"a".repeat(201)}.txt`));
expect(res.status).toBe(400);
expect(await res.text()).toBe("invalid path");
{
name: "rejects oversized media files",
mediaPath: "big",
expectedStatus: 413,
expectedBody: "too large",
setup: async () => {
const file = await writeMediaFile("big", "");
await fs.truncate(file, MEDIA_MAX_BYTES + 1);
},
},
{
name: "returns not found for missing media IDs",
mediaPath: "missing-file",
expectedStatus: 404,
expectedBody: "not found",
expectedNoSniff: true,
},
{
name: "returns 404 when route param is missing (dot path)",
mediaPath: ".",
expectedStatus: 404,
},
{
name: "rejects overlong media id",
mediaPath: `${"a".repeat(201)}.txt`,
expectedStatus: 400,
expectedBody: "invalid path",
},
] as const)("%#", async (testCase) => {
await expectFetchedMediaCase(testCase);
});
});

View File

@@ -21,6 +21,13 @@ type FsSafeModule = typeof import("../infra/fs-safe.js");
let saveMediaSource: StoreModule["saveMediaSource"];
let SafeOpenError: FsSafeModule["SafeOpenError"];
async function expectOutsideWorkspaceStoreFailure(sourcePath: string) {
await expect(saveMediaSource(sourcePath)).rejects.toMatchObject({
code: "invalid-path",
message: "Media path is outside workspace root",
});
}
describe("media store outside-workspace mapping", () => {
let tempHome: TempHomeEnv;
let home = "";
@@ -47,9 +54,6 @@ describe("media store outside-workspace mapping", () => {
new SafeOpenError("outside-workspace", "file is outside workspace root"),
);
await expect(saveMediaSource(sourcePath)).rejects.toMatchObject({
code: "invalid-path",
message: "Media path is outside workspace root",
});
await expectOutsideWorkspaceStoreFailure(sourcePath);
});
});

View File

@@ -28,6 +28,57 @@ function createMockHttpExchange() {
return { req, res };
}
function mockRedirectExchange(params: { location?: string }) {
const { req, res } = createMockHttpExchange();
res.statusCode = 302;
res.headers = params.location ? { location: params.location } : {};
return {
req,
send(cb: (value: unknown) => void) {
setImmediate(() => {
cb(res as unknown);
res.end();
});
},
};
}
function mockSuccessfulTextExchange(params: { text: string; contentType: string }) {
const { req, res } = createMockHttpExchange();
res.statusCode = 200;
res.headers = { "content-type": params.contentType };
return {
req,
send(cb: (value: unknown) => void) {
setImmediate(() => {
cb(res as unknown);
res.write(params.text);
res.end();
});
},
};
}
async function expectRedirectSaveResult(params: {
expectedText: string;
expectedContentType: string;
expectedExtension: string;
}) {
const saved = await saveMediaSource("https://example.com/start");
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(saved.contentType).toBe(params.expectedContentType);
expect(path.extname(saved.path)).toBe(params.expectedExtension);
expect(await fs.readFile(saved.path, "utf8")).toBe(params.expectedText);
const stat = await fs.stat(saved.path);
const expectedMode = process.platform === "win32" ? 0o666 : 0o644 & ~process.umask();
expect(stat.mode & 0o777).toBe(expectedMode);
}
async function expectRedirectSaveFailure(expectedMessage: string) {
await expect(saveMediaSource("https://example.com/start")).rejects.toThrow(expectedMessage);
expect(mockRequest).toHaveBeenCalledTimes(1);
}
describe("media store redirects", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
@@ -61,54 +112,33 @@ describe("media store redirects", () => {
let call = 0;
mockRequest.mockImplementation((_url, _opts, cb) => {
call += 1;
const { req, res } = createMockHttpExchange();
if (call === 1) {
res.statusCode = 302;
res.headers = { location: "https://example.com/final" };
setImmediate(() => {
cb(res as unknown);
res.end();
});
} else {
res.statusCode = 200;
res.headers = { "content-type": "text/plain" };
setImmediate(() => {
cb(res as unknown);
res.write("redirected");
res.end();
});
const exchange = mockRedirectExchange({ location: "https://example.com/final" });
exchange.send(cb);
return exchange.req;
}
return req;
const exchange = mockSuccessfulTextExchange({
text: "redirected",
contentType: "text/plain",
});
exchange.send(cb);
return exchange.req;
});
const saved = await saveMediaSource("https://example.com/start");
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(saved.contentType).toBe("text/plain");
expect(path.extname(saved.path)).toBe(".txt");
expect(await fs.readFile(saved.path, "utf8")).toBe("redirected");
const stat = await fs.stat(saved.path);
const expectedMode = process.platform === "win32" ? 0o666 : 0o644 & ~process.umask();
expect(stat.mode & 0o777).toBe(expectedMode);
await expectRedirectSaveResult({
expectedText: "redirected",
expectedContentType: "text/plain",
expectedExtension: ".txt",
});
});
it("fails when redirect response omits location header", async () => {
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
const { req, res } = createMockHttpExchange();
res.statusCode = 302;
res.headers = {};
setImmediate(() => {
cb(res as unknown);
res.end();
});
return req;
const exchange = mockRedirectExchange({});
exchange.send(cb);
return exchange.req;
});
await expect(saveMediaSource("https://example.com/start")).rejects.toThrow(
"Redirect loop or missing Location header",
);
expect(mockRequest).toHaveBeenCalledTimes(1);
await expectRedirectSaveFailure("Redirect loop or missing Location header");
});
});

View File

@@ -35,41 +35,23 @@ describe("media store", () => {
return await fn(store, home);
}
it("creates and returns media directory", async () => {
async function expectOriginalFilenameCase(params: {
filename: string;
expected: string;
basePath?: string;
}) {
await withTempStore(async (store) => {
expect(
store.extractOriginalFilename(`${params.basePath ?? "/path/to"}/${params.filename}`),
).toBe(params.expected);
});
}
async function expectRetryAfterPrunedWriteCase(params: {
segment: string;
run: (store: typeof import("./store.js"), home: string) => Promise<{ path: string }>;
}) {
await withTempStore(async (store, home) => {
const dir = await store.ensureMediaDir();
expect(isPathWithinBase(home, dir)).toBe(true);
expect(path.normalize(dir)).toContain(`${path.sep}.openclaw${path.sep}media`);
const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true);
});
});
it("saves buffers and enforces size limit", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("hello");
const saved = await store.saveMediaBuffer(buf, "text/plain");
const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buf.length);
expect(saved.contentType).toBe("text/plain");
expect(saved.path.endsWith(".txt")).toBe(true);
const jpeg = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#123456" },
})
.jpeg({ quality: 80 })
.toBuffer();
const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg");
expect(savedJpeg.contentType).toBe("image/jpeg");
expect(savedJpeg.path.endsWith(".jpg")).toBe(true);
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow("Media exceeds 5MB limit");
});
});
it("retries buffer writes when cleanup prunes the target directory", async () => {
await withTempStore(async (store) => {
const originalWriteFile = fs.writeFile.bind(fs);
let injectedEnoent = false;
vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => {
@@ -77,7 +59,7 @@ describe("media store", () => {
if (
!injectedEnoent &&
typeof filePath === "string" &&
filePath.includes(`${path.sep}race-buffer${path.sep}`)
filePath.includes(`${path.sep}${params.segment}${path.sep}`)
) {
injectedEnoent = true;
await fs.rm(path.dirname(filePath), { recursive: true, force: true });
@@ -88,160 +70,378 @@ describe("media store", () => {
return await originalWriteFile(...args);
});
const saved = await store.saveMediaBuffer(Buffer.from("hello"), "text/plain", "race-buffer");
const saved = await params.run(store, home);
const savedStat = await fs.stat(saved.path);
expect(injectedEnoent).toBe(true);
expect(savedStat.isFile()).toBe(true);
});
}
async function expectSavedOriginalFilenameCase(params: {
originalFilename?: string;
expectedIdPattern: RegExp;
expectedExtractedFilename?: string;
expectUuidOnly?: boolean;
maxBaseNameLength?: number;
}) {
await withTempStore(async (store) => {
const saved = await store.saveMediaBuffer(
Buffer.from("test content"),
"text/plain",
"inbound",
5 * 1024 * 1024,
params.originalFilename,
);
expect(saved.id).toMatch(params.expectedIdPattern);
if (params.expectedExtractedFilename) {
expect(store.extractOriginalFilename(saved.path)).toBe(params.expectedExtractedFilename);
}
if (params.expectUuidOnly) {
expect(saved.id).not.toContain("---");
}
if (params.maxBaseNameLength !== undefined) {
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(params.maxBaseNameLength);
}
});
}
async function expectSavedSourceCase(params: {
relativeSourcePath: string;
contents: string | Buffer;
expectedContentType?: string;
expectedExtension?: string;
mutateSource?: (filePath: string) => Promise<void>;
assertSaved: (saved: Awaited<ReturnType<typeof store.saveMediaSource>>) => Promise<void> | void;
}) {
await withTempStore(async (store, home) => {
const sourcePath = path.join(home, params.relativeSourcePath);
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, params.contents);
await params.mutateSource?.(sourcePath);
const saved = await store.saveMediaSource(sourcePath);
if (params.expectedContentType) {
expect(saved.contentType).toBe(params.expectedContentType);
}
if (params.expectedExtension) {
expect(path.extname(saved.path)).toBe(params.expectedExtension);
}
await params.assertSaved(saved);
});
}
async function expectCleanedSavedSourceCase(params: {
relativeSourcePath: string;
contents: string | Buffer;
expectedExtension: string;
expectedSize: number;
}) {
await expectSavedSourceCase({
relativeSourcePath: params.relativeSourcePath,
contents: params.contents,
expectedExtension: params.expectedExtension,
assertSaved: async (saved) => {
expect(saved.size).toBe(params.expectedSize);
const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true);
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
},
});
}
async function expectSavedBufferCase(params: {
buffer: Buffer;
contentType?: string;
expectedContentType: string;
expectedExtension: string;
assertSaved?: (
saved: Awaited<ReturnType<typeof store.saveMediaBuffer>>,
buffer: Buffer,
) => Promise<void> | void;
}) {
await withTempStore(async (store) => {
const saved = await store.saveMediaBuffer(params.buffer, params.contentType);
expect(saved.contentType).toBe(params.expectedContentType);
expect(saved.path.endsWith(params.expectedExtension)).toBe(true);
await params.assertSaved?.(saved, params.buffer);
});
}
async function expectRejectedSourceCase(params: {
relativeSourcePath?: string;
setupSource?: (home: string) => Promise<string>;
expectedError: string | Record<string, unknown>;
}) {
await withTempStore(async (store, home) => {
const sourcePath =
params.setupSource !== undefined
? await params.setupSource(home)
: path.join(home, params.relativeSourcePath ?? "");
const rejection = expect(store.saveMediaSource(sourcePath)).rejects;
if (typeof params.expectedError === "string") {
await rejection.toThrow(params.expectedError);
return;
}
await rejection.toMatchObject(params.expectedError);
});
}
async function createSymlinkSource(home: string) {
const target = path.join(home, "sensitive.txt");
const source = path.join(home, "source.txt");
await fs.writeFile(target, "sensitive");
await fs.symlink(target, source);
return source;
}
async function expectCleanupBehaviorCase(params: {
setup: (store: typeof import("./store.js")) => Promise<{
removedFiles: string[];
preservedFiles: string[];
removedDirs?: string[];
preservedDirs?: string[];
}>;
run: (store: typeof import("./store.js")) => Promise<void>;
}) {
await withTempStore(async (store) => {
const state = await params.setup(store);
await params.run(store);
for (const removedFile of state.removedFiles) {
await expect(fs.stat(removedFile)).rejects.toThrow();
}
for (const preservedFile of state.preservedFiles) {
const stat = await fs.stat(preservedFile);
expect(stat.isFile()).toBe(true);
}
for (const removedDir of state.removedDirs ?? []) {
await expect(fs.stat(removedDir)).rejects.toThrow();
}
for (const preservedDir of state.preservedDirs ?? []) {
const stat = await fs.stat(preservedDir);
expect(stat.isDirectory()).toBe(true);
}
});
}
async function expectTempStoreCase(run: () => Promise<void>) {
await run();
}
it.each([
{
name: "creates and returns media directory",
run: async () => {
await withTempStore(async (store, home) => {
const dir = await store.ensureMediaDir();
expect(isPathWithinBase(home, dir)).toBe(true);
expect(path.normalize(dir)).toContain(`${path.sep}.openclaw${path.sep}media`);
const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true);
});
},
},
{
name: "enforces the media size limit",
run: async () => {
await withTempStore(async (store) => {
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow("Media exceeds 5MB limit");
});
},
},
{
name: "retries buffer writes when cleanup prunes the target directory",
run: async () => {
await expectRetryAfterPrunedWriteCase({
segment: "race-buffer",
run: async (store) => {
return await store.saveMediaBuffer(Buffer.from("hello"), "text/plain", "race-buffer");
},
});
},
},
{
name: "retries local-source writes when cleanup prunes the target directory",
run: async () => {
await expectRetryAfterPrunedWriteCase({
segment: "race-source",
run: async (store, home) => {
const srcFile = path.join(home, "tmp-src-race.txt");
await fs.writeFile(srcFile, "local file");
return await store.saveMediaSource(srcFile, undefined, "race-source");
},
});
},
},
{
name: "rejects directory sources with typed error code",
run: async () => {
await expectRejectedSourceCase({
setupSource: async (home) => home,
expectedError: { code: "not-file" },
});
},
},
{
name: "cleans old media files in first-level subdirectories",
run: async () => {
await withTempStore(async (store) => {
const saved = await store.saveMediaBuffer(Buffer.from("nested"), "text/plain", "inbound");
const inboundDir = path.dirname(saved.path);
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
const inboundStat = await fs.stat(inboundDir);
expect(inboundStat.isDirectory()).toBe(true);
});
},
},
] as const)("$name", async ({ run }) => {
await expectTempStoreCase(run);
});
it.each([
{
name: "saves text buffers with the expected size and extension",
buffer: Buffer.from("hello"),
contentType: "text/plain",
expectedContentType: "text/plain",
expectedExtension: ".txt",
assertSaved: async (
saved: Awaited<ReturnType<typeof store.saveMediaBuffer>>,
buffer: Buffer,
) => {
const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buffer.length);
},
},
{
name: "saves jpeg buffers with the detected extension",
bufferFactory: async () => {
return await sharp({
create: { width: 2, height: 2, channels: 3, background: "#123456" },
})
.jpeg({ quality: 80 })
.toBuffer();
},
contentType: "image/jpeg",
expectedContentType: "image/jpeg",
expectedExtension: ".jpg",
},
] as const)("$name", async (testCase) => {
const buffer =
"bufferFactory" in testCase && testCase.bufferFactory
? await testCase.bufferFactory()
: testCase.buffer;
await expectSavedBufferCase({
buffer,
contentType: testCase.contentType,
expectedContentType: testCase.expectedContentType,
expectedExtension: testCase.expectedExtension,
...("assertSaved" in testCase ? { assertSaved: testCase.assertSaved } : {}),
});
});
it("copies local files and cleans old media", async () => {
await withTempStore(async (store, home) => {
const srcFile = path.join(home, "tmp-src.txt");
await fs.mkdir(home, { recursive: true });
await fs.writeFile(srcFile, "local file");
const saved = await store.saveMediaSource(srcFile);
expect(saved.size).toBe(10);
const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true);
expect(path.extname(saved.path)).toBe(".txt");
// make the file look old and ensure cleanOldMedia removes it
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
});
});
it("retries local-source writes when cleanup prunes the target directory", async () => {
await withTempStore(async (store, home) => {
const srcFile = path.join(home, "tmp-src-race.txt");
await fs.writeFile(srcFile, "local file");
const originalWriteFile = fs.writeFile.bind(fs);
let injectedEnoent = false;
vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => {
const [filePath] = args;
if (
!injectedEnoent &&
typeof filePath === "string" &&
filePath.includes(`${path.sep}race-source${path.sep}`)
) {
injectedEnoent = true;
await fs.rm(path.dirname(filePath), { recursive: true, force: true });
const err = new Error("missing dir") as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
}
return await originalWriteFile(...args);
});
const saved = await store.saveMediaSource(srcFile, undefined, "race-source");
const savedStat = await fs.stat(saved.path);
expect(injectedEnoent).toBe(true);
expect(savedStat.isFile()).toBe(true);
await expectCleanedSavedSourceCase({
relativeSourcePath: "tmp-src.txt",
contents: "local file",
expectedExtension: ".txt",
expectedSize: 10,
});
});
it.runIf(process.platform !== "win32")("rejects symlink sources", async () => {
await withTempStore(async (store, home) => {
const target = path.join(home, "sensitive.txt");
const source = path.join(home, "source.txt");
await fs.writeFile(target, "sensitive");
await fs.symlink(target, source);
await expect(store.saveMediaSource(source)).rejects.toThrow("symlink");
await expect(store.saveMediaSource(source)).rejects.toMatchObject({ code: "invalid-path" });
await expectRejectedSourceCase({
setupSource: createSymlinkSource,
expectedError: "symlink",
});
await expectRejectedSourceCase({
setupSource: createSymlinkSource,
expectedError: { code: "invalid-path" },
});
});
it("rejects directory sources with typed error code", async () => {
await withTempStore(async (store, home) => {
await expect(store.saveMediaSource(home)).rejects.toMatchObject({ code: "not-file" });
});
});
it("cleans old media files in first-level subdirectories", async () => {
await withTempStore(async (store) => {
const saved = await store.saveMediaBuffer(Buffer.from("nested"), "text/plain", "inbound");
const inboundDir = path.dirname(saved.path);
const past = Date.now() - 10_000;
await fs.utimes(saved.path, past / 1000, past / 1000);
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
const inboundStat = await fs.stat(inboundDir);
expect(inboundStat.isDirectory()).toBe(true);
});
});
it("cleans old media files in nested subdirectories and preserves fresh siblings", async () => {
await withTempStore(async (store) => {
const oldNested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-1", "images"),
);
const freshNested = await store.saveMediaBuffer(
Buffer.from("fresh nested"),
"text/plain",
path.join("remote-cache", "session-1", "docs"),
);
const oldFlat = await store.saveMediaBuffer(Buffer.from("old flat"), "text/plain", "inbound");
const past = Date.now() - 10_000;
await fs.utimes(oldNested.path, past / 1000, past / 1000);
await fs.utimes(oldFlat.path, past / 1000, past / 1000);
await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true });
await expect(fs.stat(oldNested.path)).rejects.toThrow();
await expect(fs.stat(oldFlat.path)).rejects.toThrow();
const freshStat = await fs.stat(freshNested.path);
expect(freshStat.isFile()).toBe(true);
await expect(fs.stat(path.dirname(oldNested.path))).rejects.toThrow();
});
});
it("keeps nested remote-cache files during shallow cleanup", async () => {
await withTempStore(async (store) => {
const nested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-1", "images"),
);
const past = Date.now() - 10_000;
await fs.utimes(nested.path, past / 1000, past / 1000);
await store.cleanOldMedia(1_000);
const stat = await fs.stat(nested.path);
expect(stat.isFile()).toBe(true);
});
});
it("prunes empty directory chains after recursive cleanup", async () => {
await withTempStore(async (store) => {
const nested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-prune", "images"),
);
const mediaDir = await store.ensureMediaDir();
const sessionDir = path.dirname(path.dirname(nested.path));
const remoteCacheDir = path.dirname(sessionDir);
const past = Date.now() - 10_000;
await fs.utimes(nested.path, past / 1000, past / 1000);
await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true });
await expect(fs.stat(sessionDir)).rejects.toThrow();
const remoteCacheStat = await fs.stat(remoteCacheDir);
const mediaStat = await fs.stat(mediaDir);
expect(remoteCacheStat.isDirectory()).toBe(true);
expect(mediaStat.isDirectory()).toBe(true);
});
it.each([
{
name: "cleans old media files in nested subdirectories and preserves fresh siblings",
setup: async (store: typeof import("./store.js")) => {
const oldNested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-1", "images"),
);
const freshNested = await store.saveMediaBuffer(
Buffer.from("fresh nested"),
"text/plain",
path.join("remote-cache", "session-1", "docs"),
);
const oldFlat = await store.saveMediaBuffer(
Buffer.from("old flat"),
"text/plain",
"inbound",
);
const past = Date.now() - 10_000;
await fs.utimes(oldNested.path, past / 1000, past / 1000);
await fs.utimes(oldFlat.path, past / 1000, past / 1000);
return {
removedFiles: [oldNested.path, oldFlat.path],
preservedFiles: [freshNested.path],
removedDirs: [path.dirname(oldNested.path)],
};
},
run: async (store: typeof import("./store.js")) =>
await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true }),
},
{
name: "keeps nested remote-cache files during shallow cleanup",
setup: async (store: typeof import("./store.js")) => {
const nested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-1", "images"),
);
const past = Date.now() - 10_000;
await fs.utimes(nested.path, past / 1000, past / 1000);
return {
removedFiles: [],
preservedFiles: [nested.path],
};
},
run: async (store: typeof import("./store.js")) => await store.cleanOldMedia(1_000),
},
{
name: "prunes empty directory chains after recursive cleanup",
setup: async (store: typeof import("./store.js")) => {
const nested = await store.saveMediaBuffer(
Buffer.from("old nested"),
"text/plain",
path.join("remote-cache", "session-prune", "images"),
);
const mediaDir = await store.ensureMediaDir();
const sessionDir = path.dirname(path.dirname(nested.path));
const remoteCacheDir = path.dirname(sessionDir);
const past = Date.now() - 10_000;
await fs.utimes(nested.path, past / 1000, past / 1000);
return {
removedFiles: [nested.path],
preservedFiles: [],
removedDirs: [sessionDir],
preservedDirs: [remoteCacheDir, mediaDir],
};
},
run: async (store: typeof import("./store.js")) =>
await store.cleanOldMedia(1_000, { recursive: true, pruneEmptyDirs: true }),
},
] as const)("$name", async ({ setup, run }) => {
await expectCleanupBehaviorCase({ setup, run });
});
it.runIf(process.platform !== "win32")(
@@ -268,56 +468,66 @@ describe("media store", () => {
},
);
it("sets correct mime for xlsx by extension", async () => {
await withTempStore(async (store, home) => {
const xlsxPath = path.join(home, "sheet.xlsx");
await fs.mkdir(home, { recursive: true });
await fs.writeFile(xlsxPath, "not really an xlsx");
const saved = await store.saveMediaSource(xlsxPath);
expect(saved.contentType).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
expect(path.extname(saved.path)).toBe(".xlsx");
});
});
it("renames media based on detected mime even when extension is wrong", async () => {
await withTempStore(async (store, home) => {
const pngBytes = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
})
.png()
.toBuffer();
const bogusExt = path.join(home, "image-wrong.bin");
await fs.writeFile(bogusExt, pngBytes);
const saved = await store.saveMediaSource(bogusExt);
expect(saved.contentType).toBe("image/png");
expect(path.extname(saved.path)).toBe(".png");
const buf = await fs.readFile(saved.path);
expect(buf.equals(pngBytes)).toBe(true);
});
});
it("sniffs xlsx mime for zip buffers and renames extension", async () => {
await withTempStore(async (store, home) => {
const zip = new JSZip();
zip.file(
"[Content_Types].xml",
'<Types><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>',
);
zip.file("xl/workbook.xml", "<workbook/>");
const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" });
const bogusExt = path.join(home, "sheet.bin");
await fs.writeFile(bogusExt, fakeXlsx);
const saved = await store.saveMediaSource(bogusExt);
expect(saved.contentType).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
expect(path.extname(saved.path)).toBe(".xlsx");
it.each([
{
name: "sets correct mime for xlsx by extension",
relativeSourcePath: "sheet.xlsx",
contents: "not really an xlsx",
expectedContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
expectedExtension: ".xlsx",
assertSaved: async () => {},
},
{
name: "renames media based on detected mime even when extension is wrong",
relativeSourcePath: "image-wrong.bin",
contentsFactory: async () => {
return await sharp({
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
})
.png()
.toBuffer();
},
expectedContentType: "image/png",
expectedExtension: ".png",
assertSaved: async (
saved: Awaited<ReturnType<typeof store.saveMediaSource>>,
contents: Buffer,
) => {
const buf = await fs.readFile(saved.path);
expect(buf.equals(contents)).toBe(true);
},
},
{
name: "sniffs xlsx mime for zip buffers and renames extension",
relativeSourcePath: "sheet.bin",
contentsFactory: async () => {
const zip = new JSZip();
zip.file(
"[Content_Types].xml",
'<Types><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>',
);
zip.file("xl/workbook.xml", "<workbook/>");
return await zip.generateAsync({ type: "nodebuffer" });
},
expectedContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
expectedExtension: ".xlsx",
assertSaved: async () => {},
},
] as const)("$name", async (testCase) => {
const contents =
"contentsFactory" in testCase && testCase.contentsFactory
? await testCase.contentsFactory()
: testCase.contents;
await expectSavedSourceCase({
relativeSourcePath: testCase.relativeSourcePath,
contents,
expectedContentType: testCase.expectedContentType,
expectedExtension: testCase.expectedExtension,
assertSaved: async (saved) => {
if ("assertSaved" in testCase) {
await testCase.assertSaved(saved, contents as Buffer);
}
},
});
});
@@ -334,8 +544,10 @@ describe("media store", () => {
try {
const storeWithMock = await import("./store.js");
const buf = Buffer.from("fake-audio");
const saved = await storeWithMock.saveMediaBuffer(buf, "audio/ogg; codecs=opus");
const saved = await storeWithMock.saveMediaBuffer(
Buffer.from("fake-audio"),
"audio/ogg; codecs=opus",
);
expect(path.extname(saved.path)).toBe(".ogg");
expect(saved.path.startsWith(home)).toBe(true);
} finally {
@@ -345,112 +557,71 @@ describe("media store", () => {
});
describe("extractOriginalFilename", () => {
it("extracts original filename from embedded pattern", async () => {
await withTempStore(async (store) => {
// Pattern: {original}---{uuid}.{ext}
const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/path/to/${filename}`);
expect(result).toBe("report.pdf");
});
});
it("handles uppercase UUID pattern", async () => {
await withTempStore(async (store) => {
const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx";
const result = store.extractOriginalFilename(`/media/inbound/${filename}`);
expect(result).toBe("Document.docx");
});
});
it("falls back to basename for non-matching patterns", async () => {
await withTempStore(async (store) => {
// UUID-only filename (legacy format)
const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly);
// Regular filename without embedded pattern
expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt");
// Filename with --- but invalid UUID part
expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt");
});
});
it("preserves original name with special characters", async () => {
await withTempStore(async (store) => {
const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/media/${filename}`);
expect(result).toBe("报告_2024.pdf");
});
it.each([
{
name: "extracts original filename from embedded pattern",
filename: "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
expected: "report.pdf",
},
{
name: "handles uppercase UUID pattern",
filename: "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx",
expected: "Document.docx",
basePath: "/media/inbound",
},
{
name: "falls back to basename for UUID-only filenames",
filename: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
expected: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
basePath: "/path",
},
{
name: "falls back to basename for regular filenames",
filename: "regular.txt",
expected: "regular.txt",
},
{
name: "falls back to basename for invalid UUID suffixes",
filename: "foo---bar.txt",
expected: "foo---bar.txt",
},
{
name: "preserves original name with special characters",
filename: "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
expected: "报告_2024.pdf",
basePath: "/media",
},
] as const)("$name", async ({ filename, expected, basePath }) => {
await expectOriginalFilenameCase({ filename, expected, basePath });
});
});
describe("saveMediaBuffer with originalFilename", () => {
it("embeds original filename in stored path when provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test content");
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"report.txt",
);
// Should contain the original name and a UUID pattern
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/);
expect(saved.path).toContain("report---");
// Should be able to extract original name
const extracted = store.extractOriginalFilename(saved.path);
expect(extracted).toBe("report.txt");
});
});
it("sanitizes unsafe characters in original filename", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
// Filename with unsafe chars: < > : " / \ | ? *
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"my<file>:test.txt",
);
// Unsafe chars should be replaced with underscores
expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/);
});
});
it("truncates long original filenames", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const longName = "a".repeat(100) + ".txt";
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
longName,
);
// Original name should be truncated to 60 chars
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(60);
});
});
it("falls back to UUID-only when originalFilename not provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound");
// Should be UUID-only pattern (legacy behavior)
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/);
expect(saved.id).not.toContain("---");
});
it.each([
{
name: "embeds original filename in stored path when provided",
originalFilename: "report.txt",
expectedIdPattern: /^report---[a-f0-9-]{36}\.txt$/,
expectedExtractedFilename: "report.txt",
},
{
name: "sanitizes unsafe characters in original filename",
originalFilename: "my<file>:test.txt",
expectedIdPattern: /^my_file_test---[a-f0-9-]{36}\.txt$/,
},
{
name: "truncates long original filenames",
originalFilename: `${"a".repeat(100)}.txt`,
expectedIdPattern: /^a+---[a-f0-9-]{36}\.txt$/,
maxBaseNameLength: 60,
},
{
name: "falls back to UUID-only when originalFilename not provided",
expectedIdPattern: /^[a-f0-9-]{36}\.txt$/,
expectUuidOnly: true,
},
] as const)("$name", async (testCase) => {
await expectSavedOriginalFilenameCase(testCase);
});
});
});

View File

@@ -26,56 +26,86 @@ afterAll(async () => {
});
describe("loadWebMedia", () => {
it("allows localhost file URLs for local files", async () => {
const fileUrl = pathToFileURL(tinyPngFile);
fileUrl.hostname = "localhost";
const result = await loadWebMedia(fileUrl.href, {
function createLocalWebMediaOptions() {
return {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
});
};
}
async function expectRejectedWebMedia(
url: string,
expectedError: Record<string, unknown> | RegExp,
setup?: () => { restore?: () => void; mockRestore?: () => void } | undefined,
) {
const restoreHandle = setup?.();
try {
if (expectedError instanceof RegExp) {
await expect(loadWebMedia(url, createLocalWebMediaOptions())).rejects.toThrow(
expectedError,
);
return;
}
await expect(loadWebMedia(url, createLocalWebMediaOptions())).rejects.toMatchObject(
expectedError,
);
} finally {
restoreHandle?.mockRestore?.();
restoreHandle?.restore?.();
}
}
async function expectRejectedWebMediaWithoutFilesystemAccess(params: {
url: string;
expectedError: Record<string, unknown> | RegExp;
setup?: () => { restore?: () => void; mockRestore?: () => void } | undefined;
}) {
const realpathSpy = vi.spyOn(fs, "realpath");
try {
await expectRejectedWebMedia(params.url, params.expectedError, params.setup);
expect(realpathSpy).not.toHaveBeenCalled();
} finally {
realpathSpy.mockRestore();
}
}
async function expectLoadedWebMediaCase(url: string) {
const result = await loadWebMedia(url, createLocalWebMediaOptions());
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeGreaterThan(0);
}
it.each([
{
name: "allows localhost file URLs for local files",
createUrl: () => {
const fileUrl = pathToFileURL(tinyPngFile);
fileUrl.hostname = "localhost";
return fileUrl.href;
},
},
] as const)("$name", async ({ createUrl }) => {
await expectLoadedWebMediaCase(createUrl());
});
it("rejects remote-host file URLs before filesystem checks", async () => {
const realpathSpy = vi.spyOn(fs, "realpath");
try {
await expect(
loadWebMedia("file://attacker/share/evil.png", {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
}),
).rejects.toMatchObject({ code: "invalid-file-url" });
await expect(
loadWebMedia("file://attacker/share/evil.png", {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
}),
).rejects.toThrow(/remote hosts are not allowed/i);
expect(realpathSpy).not.toHaveBeenCalled();
} finally {
realpathSpy.mockRestore();
}
});
it("rejects Windows network paths before filesystem checks", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const realpathSpy = vi.spyOn(fs, "realpath");
try {
await expect(
loadWebMedia("\\\\attacker\\share\\evil.png", {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
}),
).rejects.toMatchObject({ code: "network-path-not-allowed" });
expect(realpathSpy).not.toHaveBeenCalled();
} finally {
realpathSpy.mockRestore();
platformSpy.mockRestore();
}
it.each([
{
name: "rejects remote-host file URLs before filesystem checks",
url: "file://attacker/share/evil.png",
expectedError: { code: "invalid-file-url" },
},
{
name: "rejects remote-host file URLs with the explicit error message before filesystem checks",
url: "file://attacker/share/evil.png",
expectedError: /remote hosts are not allowed/i,
},
{
name: "rejects Windows network paths before filesystem checks",
url: "\\\\attacker\\share\\evil.png",
expectedError: { code: "network-path-not-allowed" },
setup: () => vi.spyOn(process, "platform", "get").mockReturnValue("win32"),
},
] as const)("$name", async (testCase) => {
await expectRejectedWebMediaWithoutFilesystemAccess(testCase);
});
});