Files
Multi-Debrid-Downloader/tests/package-output-scope.test.ts
T
Sucukdeluxe 9049489e87 feat: track direct extraction outputs
Replace package staging assumptions with validated per-generation output records for internal ZIP and both JVM backends. Persist resume v2 using relative archive identities, complete member fingerprints, and concrete verified outputs so basename collisions, changed members, missing outputs, and unknown versions fail closed.
2026-08-22 13:37:19 +02:00

160 lines
5.0 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PackageOutputScope } from "../src/main/package-output-scope";
const tempDirs: string[] = [];
function createRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-output-scope-"));
tempDirs.push(root);
return root;
}
afterEach(() => {
for (const directory of tempDirs.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe("PackageOutputScope", () => {
it("normalizes and deduplicates complete Windows paths case-insensitively", () => {
const root = createRoot();
const outputPath = path.join(root, "Season", "Episode.mkv");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, "video");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive-a.rar"),
entryPath: "Season/Episode.mkv",
outputPath,
state: "complete",
disposition: "written"
});
scope.add({
version: 1,
archivePath: path.join(root, "archive-b.rar"),
entryPath: "season/episode.mkv",
outputPath: outputPath.toUpperCase(),
state: "complete",
disposition: "overwritten"
});
expect(scope.completeFiles()).toEqual([path.resolve(outputPath)]);
expect(scope.partialFiles()).toEqual([]);
expect(scope.records()).toHaveLength(1);
});
it.each([
"../foreign.mkv",
"folder/../../foreign.mkv",
"/absolute.mkv",
"C:\\absolute.mkv"
])("rejects unsafe archive entry path %s", (entryPath) => {
const root = createRoot();
const outputPath = path.join(root, "safe.mkv");
fs.writeFileSync(outputPath, "video");
const scope = new PackageOutputScope([root]);
expect(() => scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath,
outputPath,
state: "complete",
disposition: "written"
})).toThrow(/Ausgabepfad|entry/i);
});
it("rejects outputs outside authorized roots and unknown event versions", () => {
const root = createRoot();
const foreignRoot = createRoot();
const foreignPath = path.join(foreignRoot, "foreign.mkv");
fs.writeFileSync(foreignPath, "foreign");
const scope = new PackageOutputScope([root]);
const event = {
version: 1 as const,
archivePath: path.join(root, "archive.rar"),
entryPath: "foreign.mkv",
outputPath: foreignPath,
state: "complete" as const,
disposition: "written" as const
};
expect(() => scope.add(event)).toThrow(/autorisiert/i);
expect(() => scope.add({ ...event, outputPath: path.join(root, "safe.mkv"), version: 2 as 1 })).toThrow(/Version/i);
});
it("rejects symlinked parent chains and keeps complete and partial outputs separate", () => {
const root = createRoot();
const foreignRoot = createRoot();
const completePath = path.join(root, "complete.mkv");
const partialPath = path.join(root, "partial.mkv");
fs.writeFileSync(completePath, "complete");
fs.writeFileSync(partialPath, "partial");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "complete.mkv",
outputPath: completePath,
state: "complete",
disposition: "written"
});
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "partial.mkv",
outputPath: partialPath,
state: "partial",
disposition: "written"
});
expect(scope.completeFiles()).toEqual([path.resolve(completePath)]);
expect(scope.partialFiles()).toEqual([path.resolve(partialPath)]);
const link = path.join(root, "linked");
try {
fs.symlinkSync(foreignRoot, link, "junction");
} catch {
return;
}
const linkedOutput = path.join(link, "linked.mkv");
fs.writeFileSync(path.join(foreignRoot, "linked.mkv"), "foreign");
expect(() => scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "linked/linked.mkv",
outputPath: linkedOutput,
state: "complete",
disposition: "written"
})).toThrow(/symbol|reparse/i);
});
it("updates ownership after rename and removal without enumerating the root", () => {
const root = createRoot();
const sourcePath = path.join(root, "source.mkv");
const targetPath = path.join(root, "renamed.mkv");
fs.writeFileSync(sourcePath, "video");
const scope = new PackageOutputScope([root]);
scope.add({
version: 1,
archivePath: path.join(root, "archive.rar"),
entryPath: "source.mkv",
outputPath: sourcePath,
state: "complete",
disposition: "written"
});
fs.renameSync(sourcePath, targetPath);
scope.replacePath(sourcePath, targetPath);
expect(scope.completeFiles()).toEqual([path.resolve(targetPath)]);
expect(scope.removePath(targetPath)).toBe(true);
expect(scope.completeFiles()).toEqual([]);
});
});