Release v1.4.20 with comprehensive audit fixes (140 issues) and expanded test coverage
- Speed calculation: raised minimum elapsed floor to 0.5s preventing unrealistic spikes - Reconnect: exponential backoff with consecutive counter, clock regression protection - Download engine: retry byte tracking (itemContributedBytes), mkdir before createWriteStream, content-length validation - Fire-and-forget promises: all void promises now have .catch() error handlers - Session recovery: normalize stale active statuses to queued on crash recovery, clear speedBps - Storage: config backup (.bak) before overwrite, EXDEV cross-device rename fallback with type guard - IPC security: input validation on all string/array IPC handlers, CSP headers in production - Main process: clipboard memory limit (50KB), installer timing increased to 800ms - Debrid: attribute-order-independent meta tag regex for Rapidgator filename extraction - Constants: named constants for magic numbers (MAX_MANIFEST_FILE_BYTES, MAX_LINK_ARTIFACT_BYTES, etc.) - Extractor/integrity: use shared constants, document password visibility and TOCTOU limitations - Tests: 103 tests total (55 new), covering utils, storage, integrity, cleanup, extractor, debrid, update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
556f0672dc
commit
63fd402083
@@ -37,4 +37,56 @@ describe("cleanup", () => {
|
||||
expect(links).toBeGreaterThan(0);
|
||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cleans up archive files in nested directories", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// Create nested directory structure with archive files
|
||||
const sub1 = path.join(dir, "season1");
|
||||
const sub2 = path.join(dir, "season1", "extras");
|
||||
fs.mkdirSync(sub2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||
// Non-archive files should be kept
|
||||
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBe(4); // 2 rar parts + zip + 7z
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||
// Non-archives kept
|
||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects link artifacts by URL content in text files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// File with link-like name containing URLs should be removed
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||
// File with link-like name but no URLs should be kept
|
||||
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||
// Regular text file that doesn't match the link pattern should be kept
|
||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||
// .url files should always be removed
|
||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||
// .dlc files should always be removed
|
||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||
|
||||
const removed = removeDownloadLinkArtifacts(dir);
|
||||
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
|
||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||
// Non-matching files should be kept
|
||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+100
-1
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { DebridService } from "../src/main/debrid";
|
||||
import { DebridService, extractRapidgatorFilenameFromHtml, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -368,3 +368,102 @@ describe("debrid service", () => {
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeResolvedFilename", () => {
|
||||
it("strips HTML entities", () => {
|
||||
expect(normalizeResolvedFilename("Show.S01E01.German.DL.720p.part01.rar")).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
||||
expect(normalizeResolvedFilename("File&Name.part1.rar")).toBe("File&Name.part1.rar");
|
||||
expect(normalizeResolvedFilename("File"Name".part1.rar")).toBe('File"Name".part1.rar');
|
||||
});
|
||||
|
||||
it("strips HTML tags and collapses whitespace", () => {
|
||||
// Tags are replaced by spaces, then multiple spaces collapsed
|
||||
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
|
||||
expect(result).toBe("Show.S01E01 .part01.rar");
|
||||
|
||||
// Entity decoding happens before tag removal, so <...> becomes <...> then gets stripped
|
||||
const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar");
|
||||
expect(entityTagResult).toBe("File .part1.rar");
|
||||
});
|
||||
|
||||
it("strips 'download file' prefix", () => {
|
||||
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar")).toBe("Show.S01E01.part01.rar");
|
||||
expect(normalizeResolvedFilename("download file Movie.2024.mkv")).toBe("Movie.2024.mkv");
|
||||
});
|
||||
|
||||
it("strips Rapidgator suffix", () => {
|
||||
expect(normalizeResolvedFilename("Show.S01E01.part01.rar - Rapidgator")).toBe("Show.S01E01.part01.rar");
|
||||
expect(normalizeResolvedFilename("Movie.mkv | Rapidgator.net")).toBe("Movie.mkv");
|
||||
});
|
||||
|
||||
it("returns empty for opaque or non-filename values", () => {
|
||||
expect(normalizeResolvedFilename("")).toBe("");
|
||||
expect(normalizeResolvedFilename("just some text")).toBe("");
|
||||
expect(normalizeResolvedFilename("e51f6809bb6ca615601f5ac5db433737")).toBe("");
|
||||
expect(normalizeResolvedFilename("download.bin")).toBe("");
|
||||
});
|
||||
|
||||
it("handles combined transforms", () => {
|
||||
// "Download file" prefix stripped, & decoded to &, "- Rapidgator" suffix stripped
|
||||
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
|
||||
.toBe("Show.S01E01.part01.rar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filenameFromRapidgatorUrlPath", () => {
|
||||
it("extracts filename from standard rapidgator URL", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||
.toBe("Show.S01E01.part01.rar");
|
||||
});
|
||||
|
||||
it("extracts filename without .html suffix", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Movie.2024.mkv"))
|
||||
.toBe("Movie.2024.mkv");
|
||||
});
|
||||
|
||||
it("returns empty for hash-only URL paths", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/e51f6809bb6ca615601f5ac5db433737"))
|
||||
.toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for invalid URLs", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("not-a-url")).toBe("");
|
||||
expect(filenameFromRapidgatorUrlPath("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles URL-encoded path segments", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/id/Show%20Name.S01E01.part01.rar.html"))
|
||||
.toBe("Show Name.S01E01.part01.rar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRapidgatorFilenameFromHtml", () => {
|
||||
it("extracts filename from title tag", () => {
|
||||
const html = "<html><head><title>Download file Show.S01E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>";
|
||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
||||
});
|
||||
|
||||
it("extracts filename from og:title meta tag", () => {
|
||||
const html = '<html><head><meta property="og:title" content="Movie.2024.German.DL.1080p.mkv"></head></html>';
|
||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
||||
});
|
||||
|
||||
it("extracts filename from reversed og:title attribute order", () => {
|
||||
const html = '<html><head><meta content="Movie.2024.German.DL.1080p.mkv" property="og:title"></head></html>';
|
||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
||||
});
|
||||
|
||||
it("returns empty for HTML without recognizable filenames", () => {
|
||||
const html = "<html><head><title>Rapidgator: Fast, Pair and Unlimited</title></head><body>No file here</body></html>";
|
||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty for empty HTML", () => {
|
||||
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts from File name label in page body", () => {
|
||||
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
|
||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,4 +447,111 @@ describe("extractor", () => {
|
||||
expect(fs.existsSync(path.join(targetDir, "safe.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("builds external extract args for 7z-style extractor", () => {
|
||||
const args7z = buildExternalExtractArgs("7z.exe", "archive.7z", "C:\\target", "overwrite");
|
||||
expect(args7z[0]).toBe("x");
|
||||
expect(args7z).toContain("-y");
|
||||
expect(args7z).toContain("-aoa");
|
||||
expect(args7z).toContain("-p");
|
||||
expect(args7z).toContain("archive.7z");
|
||||
expect(args7z).toContain("-oC:\\target");
|
||||
});
|
||||
|
||||
it("builds 7z args with skip conflict mode", () => {
|
||||
const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "skip");
|
||||
expect(args).toContain("-aos");
|
||||
});
|
||||
|
||||
it("builds 7z args with rename conflict mode", () => {
|
||||
const args = buildExternalExtractArgs("7z", "archive.zip", "/out", "rename");
|
||||
expect(args).toContain("-aou");
|
||||
});
|
||||
|
||||
it("builds 7z args with password", () => {
|
||||
const args = buildExternalExtractArgs("7z", "archive.7z", "/out", "overwrite", "secretpass");
|
||||
expect(args).toContain("-psecretpass");
|
||||
});
|
||||
|
||||
it("builds WinRAR args with empty password uses -p-", () => {
|
||||
const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "overwrite", "");
|
||||
expect(args).toContain("-p-");
|
||||
});
|
||||
|
||||
it("builds WinRAR args with skip conflict mode uses -o-", () => {
|
||||
const args = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "/out", "skip");
|
||||
expect(args[1]).toBe("-o-");
|
||||
});
|
||||
|
||||
it("collects split zip companion parts for cleanup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const mainZip = path.join(packageDir, "release.zip");
|
||||
const z01 = path.join(packageDir, "release.z01");
|
||||
const z02 = path.join(packageDir, "release.z02");
|
||||
const otherZip = path.join(packageDir, "other.zip");
|
||||
|
||||
fs.writeFileSync(mainZip, "a", "utf8");
|
||||
fs.writeFileSync(z01, "b", "utf8");
|
||||
fs.writeFileSync(z02, "c", "utf8");
|
||||
fs.writeFileSync(otherZip, "x", "utf8");
|
||||
|
||||
const targets = new Set(collectArchiveCleanupTargets(mainZip));
|
||||
expect(targets.has(mainZip)).toBe(true);
|
||||
expect(targets.has(z01)).toBe(true);
|
||||
expect(targets.has(z02)).toBe(true);
|
||||
expect(targets.has(otherZip)).toBe(false);
|
||||
});
|
||||
|
||||
it("collects numbered split zip parts (.zip.001, .zip.002) for cleanup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const part1 = path.join(packageDir, "movie.zip.001");
|
||||
const part2 = path.join(packageDir, "movie.zip.002");
|
||||
const part3 = path.join(packageDir, "movie.zip.003");
|
||||
const mainZip = path.join(packageDir, "movie.zip");
|
||||
const other = path.join(packageDir, "other.zip.001");
|
||||
|
||||
fs.writeFileSync(part1, "a", "utf8");
|
||||
fs.writeFileSync(part2, "b", "utf8");
|
||||
fs.writeFileSync(part3, "c", "utf8");
|
||||
fs.writeFileSync(mainZip, "d", "utf8");
|
||||
fs.writeFileSync(other, "x", "utf8");
|
||||
|
||||
const targets = new Set(collectArchiveCleanupTargets(part1));
|
||||
expect(targets.has(part1)).toBe(true);
|
||||
expect(targets.has(part2)).toBe(true);
|
||||
expect(targets.has(part3)).toBe(true);
|
||||
expect(targets.has(mainZip)).toBe(true);
|
||||
expect(targets.has(other)).toBe(false);
|
||||
});
|
||||
|
||||
it("collects old-style rar split parts (.r00, .r01) for cleanup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const mainRar = path.join(packageDir, "show.rar");
|
||||
const r00 = path.join(packageDir, "show.r00");
|
||||
const r01 = path.join(packageDir, "show.r01");
|
||||
const r02 = path.join(packageDir, "show.r02");
|
||||
|
||||
fs.writeFileSync(mainRar, "a", "utf8");
|
||||
fs.writeFileSync(r00, "b", "utf8");
|
||||
fs.writeFileSync(r01, "c", "utf8");
|
||||
fs.writeFileSync(r02, "d", "utf8");
|
||||
|
||||
const targets = new Set(collectArchiveCleanupTargets(mainRar));
|
||||
expect(targets.has(mainRar)).toBe(true);
|
||||
expect(targets.has(r00)).toBe(true);
|
||||
expect(targets.has(r01)).toBe(true);
|
||||
expect(targets.has(r02)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-1
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseHashLine, validateFileAgainstManifest } from "../src/main/integrity";
|
||||
import { parseHashLine, readHashManifest, validateFileAgainstManifest } from "../src/main/integrity";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -29,4 +29,45 @@ describe("integrity", () => {
|
||||
const result = await validateFileAgainstManifest(filePath, dir);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("skips manifest files larger than 5MB", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// Create a .md5 manifest that exceeds the 5MB limit
|
||||
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
||||
const manifestPath = path.join(dir, "hashes.md5");
|
||||
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
||||
|
||||
// Verify the file is actually > 5MB
|
||||
const stat = fs.statSync(manifestPath);
|
||||
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
// readHashManifest should skip the oversized file
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
||||
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
|
||||
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
||||
const result = parseHashLine(sha256Line);
|
||||
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses SHA1 hash lines correctly", () => {
|
||||
const sha1Line = "da39a3ee5e6b4b0d3255bfef95601890afd80709 emptyfile.bin";
|
||||
const result = parseHashLine(sha1Line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.algorithm).toBe("sha1");
|
||||
expect(result?.digest).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
||||
expect(result?.fileName).toBe("emptyfile.bin");
|
||||
});
|
||||
|
||||
it("ignores comment lines in hash manifests", () => {
|
||||
expect(parseHashLine("; This is a comment")).toBeNull();
|
||||
expect(parseHashLine("")).toBeNull();
|
||||
expect(parseHashLine(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+184
-1
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, loadSettings, normalizeSettings, saveSettings } from "../src/main/storage";
|
||||
import { createStoragePaths, emptySession, loadSession, loadSettings, normalizeSettings, saveSession, saveSettings } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -148,4 +148,187 @@ describe("settings storage", () => {
|
||||
|
||||
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
||||
});
|
||||
|
||||
it("resets stale active statuses to queued on session load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const session = emptySession();
|
||||
session.packages["pkg1"] = {
|
||||
id: "pkg1",
|
||||
name: "Test Package",
|
||||
outputDir: "/tmp/out",
|
||||
extractDir: "/tmp/extract",
|
||||
status: "downloading",
|
||||
itemIds: ["item1", "item2", "item3", "item4"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item1"] = {
|
||||
id: "item1",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file1.rar",
|
||||
provider: null,
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1024,
|
||||
downloadedBytes: 5000,
|
||||
totalBytes: 10000,
|
||||
progressPercent: 50,
|
||||
fileName: "file1.rar",
|
||||
targetPath: "/tmp/out/file1.rar",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "some error",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item2"] = {
|
||||
id: "item2",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file2.rar",
|
||||
provider: null,
|
||||
status: "paused",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "file2.rar",
|
||||
targetPath: "/tmp/out/file2.rar",
|
||||
resumable: false,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item3"] = {
|
||||
id: "item3",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file3.rar",
|
||||
provider: null,
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 10000,
|
||||
totalBytes: 10000,
|
||||
progressPercent: 100,
|
||||
fileName: "file3.rar",
|
||||
targetPath: "/tmp/out/file3.rar",
|
||||
resumable: false,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item4"] = {
|
||||
id: "item4",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file4.rar",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "file4.rar",
|
||||
targetPath: "/tmp/out/file4.rar",
|
||||
resumable: false,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
const loaded = loadSession(paths);
|
||||
|
||||
// Active statuses (downloading, paused) should be reset to "queued"
|
||||
expect(loaded.items["item1"].status).toBe("queued");
|
||||
expect(loaded.items["item2"].status).toBe("queued");
|
||||
// Speed should be cleared
|
||||
expect(loaded.items["item1"].speedBps).toBe(0);
|
||||
// lastError should be cleared for reset items
|
||||
expect(loaded.items["item1"].lastError).toBe("");
|
||||
// Completed and queued statuses should be preserved
|
||||
expect(loaded.items["item3"].status).toBe("completed");
|
||||
expect(loaded.items["item4"].status).toBe("queued");
|
||||
// Downloaded bytes should be preserved
|
||||
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
|
||||
// Package data should be preserved
|
||||
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
||||
});
|
||||
|
||||
it("returns empty session when session file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, "{{{corrupted json!!!", "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
const empty = emptySession();
|
||||
expect(loaded.packages).toEqual(empty.packages);
|
||||
expect(loaded.items).toEqual(empty.items);
|
||||
expect(loaded.packageOrder).toEqual(empty.packageOrder);
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
// Write invalid JSON to the config file
|
||||
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
const defaults = defaultSettings();
|
||||
expect(loaded.providerPrimary).toBe(defaults.providerPrimary);
|
||||
expect(loaded.maxParallel).toBe(defaults.maxParallel);
|
||||
expect(loaded.outputDir).toBe(defaults.outputDir);
|
||||
expect(loaded.cleanupMode).toBe(defaults.cleanupMode);
|
||||
});
|
||||
|
||||
it("applies defaults for missing fields when loading old config", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
// Write a minimal config that simulates an old version missing newer fields
|
||||
fs.writeFileSync(
|
||||
paths.configFile,
|
||||
JSON.stringify({
|
||||
token: "my-token",
|
||||
rememberToken: true,
|
||||
outputDir: "/custom/output"
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
const defaults = defaultSettings();
|
||||
|
||||
// Old fields should be preserved
|
||||
expect(loaded.token).toBe("my-token");
|
||||
expect(loaded.outputDir).toBe("/custom/output");
|
||||
|
||||
// Missing new fields should get default values
|
||||
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
||||
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
|
||||
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);
|
||||
expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode);
|
||||
expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch);
|
||||
expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray);
|
||||
expect(loaded.theme).toBe(defaults.theme);
|
||||
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
|
||||
expect(loaded.updateRepo).toBe(defaults.updateRepo);
|
||||
});
|
||||
});
|
||||
|
||||
+89
-1
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkGitHubUpdate, installLatestUpdate, normalizeUpdateRepo } from "../src/main/update";
|
||||
import { checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { APP_VERSION } from "../src/main/constants";
|
||||
import { UpdateCheckResult } from "../src/shared/types";
|
||||
|
||||
@@ -108,3 +108,91 @@ describe("update", () => {
|
||||
expect(requestedUrls.some((url) => url.includes("/releases/latest/download/"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeUpdateRepo extended", () => {
|
||||
it("handles trailing slashes and extra path segments", () => {
|
||||
expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
it("handles ssh-style git URLs", () => {
|
||||
expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
|
||||
});
|
||||
|
||||
it("returns default for malformed inputs", () => {
|
||||
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/real-debrid-downloader");
|
||||
});
|
||||
|
||||
it("handles www prefix", () => {
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRemoteNewer", () => {
|
||||
it("detects newer major version", () => {
|
||||
expect(isRemoteNewer("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects newer minor version", () => {
|
||||
expect(isRemoteNewer("1.2.0", "1.3.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects newer patch version", () => {
|
||||
expect(isRemoteNewer("1.2.3", "1.2.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for same version", () => {
|
||||
expect(isRemoteNewer("1.2.3", "1.2.3")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for older version", () => {
|
||||
expect(isRemoteNewer("2.0.0", "1.0.0")).toBe(false);
|
||||
expect(isRemoteNewer("1.3.0", "1.2.0")).toBe(false);
|
||||
expect(isRemoteNewer("1.2.4", "1.2.3")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles versions with different segment counts", () => {
|
||||
expect(isRemoteNewer("1.2", "1.2.1")).toBe(true);
|
||||
expect(isRemoteNewer("1.2.1", "1.2")).toBe(false);
|
||||
expect(isRemoteNewer("1", "1.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles v-prefix in version strings", () => {
|
||||
expect(isRemoteNewer("v1.0.0", "v2.0.0")).toBe(true);
|
||||
expect(isRemoteNewer("v1.0.0", "v1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionParts", () => {
|
||||
it("parses standard version strings", () => {
|
||||
expect(parseVersionParts("1.2.3")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("10.20.30")).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("strips v prefix", () => {
|
||||
expect(parseVersionParts("v1.2.3")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("V1.2.3")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles single segment", () => {
|
||||
expect(parseVersionParts("5")).toEqual([5]);
|
||||
});
|
||||
|
||||
it("handles version with pre-release suffix", () => {
|
||||
// Non-numeric suffixes are stripped per part
|
||||
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles empty and whitespace", () => {
|
||||
expect(parseVersionParts("")).toEqual([0]);
|
||||
expect(parseVersionParts(" ")).toEqual([0]);
|
||||
});
|
||||
|
||||
it("handles versions with extra dots", () => {
|
||||
expect(parseVersionParts("1.2.3.4")).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,4 +42,62 @@ describe("utils", () => {
|
||||
expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves unicode filenames", () => {
|
||||
expect(sanitizeFilename("日本語ファイル.txt")).toBe("日本語ファイル.txt");
|
||||
expect(sanitizeFilename("Ünïcödé Tëst.mkv")).toBe("Ünïcödé Tëst.mkv");
|
||||
expect(sanitizeFilename("파일이름.rar")).toBe("파일이름.rar");
|
||||
expect(sanitizeFilename("файл.zip")).toBe("файл.zip");
|
||||
});
|
||||
|
||||
it("handles very long filenames", () => {
|
||||
const longName = "a".repeat(300);
|
||||
const result = sanitizeFilename(longName);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// The function should return a non-empty string and not crash
|
||||
expect(result).toBe(longName);
|
||||
});
|
||||
|
||||
it("formats eta with very large values without crashing", () => {
|
||||
const result = formatEta(999999);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// 999999 seconds = 277h 46m 39s
|
||||
expect(result).toBe("277:46:39");
|
||||
});
|
||||
|
||||
it("formats eta with edge cases", () => {
|
||||
expect(formatEta(0)).toBe("00:00");
|
||||
expect(formatEta(NaN)).toBe("--");
|
||||
expect(formatEta(Infinity)).toBe("--");
|
||||
expect(formatEta(Number.MAX_SAFE_INTEGER)).toMatch(/^\d+:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("extracts filenames from URLs with encoded characters", () => {
|
||||
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
||||
// %C3%A9 decodes to e-acute (UTF-8), which is preserved
|
||||
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
|
||||
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
|
||||
// Malformed percent-encoding should not crash
|
||||
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles looksLikeOpaqueFilename edge cases", () => {
|
||||
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
|
||||
expect(looksLikeOpaqueFilename("")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
|
||||
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
||||
// Short hex strings (< 24 chars) are NOT considered opaque
|
||||
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
||||
// Real filename with extension
|
||||
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user