Files
yaak-mountain-loop/packages/plugin-runtime/src/importFiles.test.ts
T

225 lines
9.5 KiB
TypeScript

import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Context, ImportRequest, ImportSource } from "@yaakapp/api";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { createImportFiles, runImporter } from "./importFiles";
const dirs: string[] = [];
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
const input = (name: string, data: Uint8Array): ImportRequest => ({
content: "",
source: { type: "file", name, base64: Buffer.from(data).toString("base64") },
});
async function zip(name = "import-files.zip") {
return input(name, await readFile(new URL(`./fixtures/${name}`, import.meta.url)));
}
const ctx = {} as Context;
describe("import file tree", () => {
test("exposes legacy text and single binary files through the same read API", async () => {
const session = await createImportFiles({ content: "café" });
expect(session.text).toBe("café");
expect(await session.files.readDir()).toEqual([{ name: "input", path: "input", type: "file" }]);
expect(await session.files.readTextFile("input")).toBe("café");
session.close();
await expect(session.files.readFile("input")).rejects.toThrow("closed");
const binary = await createImportFiles(input("body.bin", Uint8Array.from([255, 0, 128])));
expect(binary.isText).toBe(false);
expect([...(await binary.files.readFile("body.bin"))]).toEqual([255, 0, 128]);
await expect(binary.files.readTextFile("body.bin")).rejects.toThrow();
binary.close();
});
test("lists immediate ZIP children, including implicit/empty directories, and reads binary/UTF-8", async () => {
const session = await createImportFiles(await zip());
expect(session.files.kind).toBe("zip");
expect(session.text).toBe("");
expect(await session.files.readDir()).toEqual([
{ name: "binary.bin", path: "binary.bin", type: "file" },
{ name: "collection", path: "collection", type: "directory" },
]);
expect((await session.files.readDir("collection")).map((e) => e.name)).toEqual([
"café.yml",
"empty",
"nested",
]);
expect(await session.files.readDir("collection/empty")).toEqual([]);
expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
expect([...(await session.files.readFile("binary.bin"))]).toEqual([0, 255, 128]);
await expect(session.files.readFile("collection")).rejects.toThrow("not found");
await expect(session.files.readDir("missing")).rejects.toThrow("not found");
session.close();
});
test("a misleading ZIP suffix still dispatches as text", async () => {
const content = '{"openapi":"3.0.0","info":{"title":"Example","version":"1"},"paths":{}}';
const source = input("api.zip", Buffer.from(content));
const onImport = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Text", onImport }, ctx, source);
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: content });
const onImportSource = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Source", onImportSource }, ctx, source);
expect(onImportSource).toHaveBeenCalledExactlyOnceWith(ctx, {
source: { type: "text", name: "api.zip", text: content },
});
});
test("ZIP signatures take precedence over names, including empty archives", async () => {
const bytes = await readFile(new URL("./fixtures/import-files.zip", import.meta.url));
const empty = Buffer.alloc(22);
empty.writeUInt32LE(0x06054b50);
for (const data of [bytes, empty]) {
const source = input("collection.yaml", data);
const session = await createImportFiles(source);
try {
expect(session.files.kind).toBe("zip");
if (data === empty) expect(await session.files.readDir()).toEqual([]);
else expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
} finally {
session.close();
}
const onImport = vi.fn().mockResolvedValue(null);
expect(await runImporter({ name: "Text", onImport }, ctx, source)).toBeNull();
expect(onImport).not.toHaveBeenCalled();
}
});
test("a ZIP made by compressing a folder is rooted at that folder", async () => {
const session = await createImportFiles(await zip("wrapped.zip"));
try {
expect((await session.files.readDir()).map((e) => e.name)).toEqual(["nested", "root.txt"]);
expect(await session.files.readTextFile("nested/file.txt")).toBe("inner\n");
await expect(session.files.readDir("Demo")).rejects.toThrow("not found");
} finally {
session.close();
}
});
test("malformed input with a ZIP signature still fails archive validation", async () => {
const source = input("broken.zip", Buffer.from([0x50, 0x4b, 0x03, 0x04]));
await expect(createImportFiles(source)).rejects.toThrow();
});
test("directory view reads lazily and blocks links and escaping paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-"));
dirs.push(root);
await mkdir(path.join(root, "nested"));
await writeFile(path.join(root, "nested", "file"), "first");
await symlink(tmpdir(), path.join(root, "escape"));
const session = await createImportFiles({
content: "",
source: { type: "directory", path: root },
});
expect(await session.files.readDir()).toEqual([
{ name: "nested", path: "nested", type: "directory" },
]);
await writeFile(path.join(root, "nested", "file"), "updated");
expect(await session.files.readTextFile("nested/file")).toBe("updated");
await expect(session.files.readDir("escape")).rejects.toThrow("symbolic");
for (const p of [
"../outside",
"/absolute",
"C:/outside",
"nested/../../outside",
"nested\\file",
]) {
await expect(session.files.readFile(p)).rejects.toThrow();
}
session.close();
});
test.each(["import-traversal.zip", "import-duplicate.zip", "import-symlink.zip"])(
"rejects unsafe ZIP %s",
async (name) => {
await expect(createImportFiles(await zip(name))).rejects.toThrow();
},
);
test("ZIP entries are decompressed only on read, with a read-size limit", async () => {
const session = await createImportFiles(await zip("import-large.zip"));
expect((await session.files.readDir())[0]?.name).toBe("large.txt");
await expect(session.files.readFile("large.txt")).rejects.toThrow("32 MiB");
session.close();
});
test("the deprecated text hook preserves its arguments and skips other input", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const importer = { name: "Legacy", onImport };
await runImporter(importer, ctx, { content: "original" });
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "original" });
onImport.mockClear();
await runImporter(importer, ctx, input("empty.txt", new Uint8Array()));
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "" });
onImport.mockClear();
for (const source of [
await zip(),
input("binary", Uint8Array.from([255])),
{
content: "",
source: { type: "directory", path: "/does-not-exist" },
} satisfies ImportRequest,
]) {
expect(await runImporter(importer, ctx, source)).toBeNull();
}
expect(onImport).not.toHaveBeenCalled();
});
test("the source hook gets text for documents and a tree for directories and ZIPs", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const onImportSource = vi.fn().mockResolvedValue(null);
const importer = { name: "Both", onImport, onImportSource };
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-hooks-"));
dirs.push(root);
const cases: [ImportRequest, ImportSource["type"] | null, string?][] = [
[{ content: "pasted" }, "text", "pasted"],
[input("text.txt", Buffer.from("text")), "text", "text"],
[input("empty.txt", new Uint8Array()), "text", ""],
[input("binary", Uint8Array.from([255])), null],
[await zip(), "directory"],
[{ content: "", source: { type: "directory", path: root } }, "directory"],
];
for (const [request, type, text] of cases) {
onImportSource.mockClear();
expect(await runImporter(importer, ctx, request)).toBeNull();
if (type == null) {
expect(onImportSource).not.toHaveBeenCalled();
continue;
}
expect(onImportSource).toHaveBeenCalledOnce();
const { source } = onImportSource.mock.calls[0]![1] as { source: ImportSource };
expect(source.type).toBe(type);
if (source.type === "text") expect(source.text).toBe(text);
else await expect(source.files.readDir()).rejects.toThrow("closed");
}
onImportSource.mockResolvedValue(undefined);
await runImporter(importer, ctx, { content: "pasted" });
expect(onImport).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
test("closes file access even if the importer throws", async () => {
let saved: import("@yaakapp/api").ImportFiles | undefined;
await expect(
runImporter(
{
name: "Fails",
onImportSource(_ctx, { source }) {
if (source.type === "directory") saved = source.files;
throw new Error("failure");
},
},
ctx,
await zip(),
),
).rejects.toThrow("failure");
await expect(saved!.readDir()).rejects.toThrow("closed");
});
});