import fs from "node:fs/promises" ;
import path from "node:path" ;
import JSZip from "jszip" ;
import sharp from "sharp" ;
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest" ;
import { importFreshModule } from "../../test/helpers/import-fresh.ts" ;
import { isPathWithinBase } from "../../test/helpers/paths.js" ;
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js" ;
describe("media store" , () => {
let store: typeof import ("./store.js" );
let home = "" ;
let tempHome: TempHomeEnv;
beforeAll(async () => {
tempHome = await createTempHomeEnv("openclaw-test-home-" );
home = tempHome.home;
store = await import ("./store.js" );
});
afterAll(async () => {
try {
await tempHome.restore();
} catch {
// ignore cleanup failures in tests
}
});
afterEach(() => {
vi.restoreAllMocks();
});
async function withTempStore<T>(
fn: (store: typeof import ("./store.js" ), home: string) => Promise<T>,
): Promise<T> {
return await fn(store, home);
}
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 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}${params.segment}${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 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-${Date.now()}-${Math.random().toString(16 ).slice(2 )}.txt`,
);
await fs.writeFile(target, "sensitive" );
await fs.rm(source, { force: true });
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: "allows callers to override the default source size limit" ,
run: async () => {
await withTempStore(async (store, home) => {
const sourcePath = path.join(home, "large-source.bin" );
await fs.writeFile(sourcePath, Buffer.alloc(6 * 1024 * 1024 , 0 x41));
const saved = await store.saveMediaSource(
sourcePath,
undefined,
"outbound" ,
8 * 1024 * 1024 ,
);
expect(saved.size).toBe(6 * 1024 * 1024 );
});
},
},
{
name: "reports the effective source size limit in too-large errors" ,
run: async () => {
await withTempStore(async (store, home) => {
const sourcePath = path.join(home, "too-large-source.bin" );
await fs.writeFile(sourcePath, Buffer.alloc(7 * 1024 * 1024 , 0 x41));
await expect(
store.saveMediaSource(sourcePath, undefined, "outbound" , 6 * 1024 * 1024 ),
).rejects.toThrow("Media exceeds 6MB 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 expectCleanedSavedSourceCase({
relativeSourcePath: "tmp-src.txt" ,
contents: "local file" ,
expectedExtension: ".txt" ,
expectedSize: 10 ,
});
});
it.runIf(process.platform !== "win32" )("rejects symlink sources" , async () => {
await expectRejectedSourceCase({
setupSource: createSymlinkSource,
expectedError: "symlink" ,
});
await expectRejectedSourceCase({
setupSource: createSymlinkSource,
expectedError: { code: "invalid-path" },
});
});
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" )(
"does not follow symlinked top-level directories during recursive cleanup" ,
async () => {
await withTempStore(async (store, home) => {
const mediaDir = await store.ensureMediaDir();
const outsideDir = path.join(home, "outside-media" );
const outsideFile = path.join(outsideDir, "old.txt" );
const symlinkPath = path.join(mediaDir, "linked-dir" );
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(outsideFile, "outside" );
const past = Date.now() - 10 _000 ;
await fs.utimes(outsideFile, past / 1000 , past / 1000 );
await fs.symlink(outsideDir, symlinkPath);
await store.cleanOldMedia(1 _000 , { recursive: true , pruneEmptyDirs: true });
const outsideStat = await fs.stat(outsideFile);
const symlinkStat = await fs.lstat(symlinkPath);
expect(outsideStat.isFile()).toBe(true );
expect(symlinkStat.isSymbolicLink()).toBe(true );
});
},
);
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);
}
},
});
});
it("prefers header mime extension when sniffed mime lacks mapping" , async () => {
await withTempStore(async (_store, home) => {
vi.doMock("./mime.js" , async () => {
const actual = await vi.importActual<typeof import ("./mime.js" )>("./mime.js" );
return {
...actual,
detectMime: vi.fn(async () => "audio/opus" ),
};
});
try {
const storeWithMock = await importFreshModule<typeof import ("./store.js" )>(
import .meta.url,
"./store.js?scope=sniffed-mime-header-extension" ,
);
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 {
vi.doUnmock("./mime.js" );
}
});
});
describe("extractOriginalFilename" , () => {
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.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);
});
});
});
Messung V0.5 in Prozent C=100 H=96 G=97
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland