Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
644 lines
27 KiB
TypeScript
644 lines
27 KiB
TypeScript
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { once } from "node:events";
|
|
import AdmZip from "adm-zip";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../src/main/windows-host-diagnostics", () => ({
|
|
getWindowsHostDiagnostics: () => ({
|
|
collectedAt: "2026-03-09T00:00:03.000Z",
|
|
supported: true,
|
|
platform: "win32",
|
|
crashControl: {
|
|
crashDumpEnabled: 3,
|
|
minidumpDir: "C:\\Windows\\Minidumps",
|
|
dumpFile: "C:\\Windows\\MEMORY.DMP",
|
|
overwrite: 1,
|
|
logEvent: 1,
|
|
autoReboot: 1
|
|
},
|
|
recentKernelPower: [
|
|
{
|
|
timeCreated: "2026-03-09T00:00:04.000Z",
|
|
id: 41,
|
|
providerName: "Microsoft-Windows-Kernel-Power",
|
|
levelDisplayName: "Critical",
|
|
message: "unexpected restart",
|
|
bugcheckCode: "0",
|
|
bugcheckCodeHex: "",
|
|
reportId: ""
|
|
}
|
|
],
|
|
recentWerKernel: [],
|
|
recentKernelDump: [],
|
|
recentAppCrashes: [],
|
|
recentMinidumps: [],
|
|
assessmentHints: ["watchdog hint"],
|
|
errors: []
|
|
})
|
|
}));
|
|
|
|
import { defaultSettings } from "../src/main/constants";
|
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
|
import { createRendererState } from "../src/main/renderer-state";
|
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
|
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
|
|
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
|
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
|
|
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
|
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
|
|
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
|
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
|
|
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
|
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
|
|
import type { DownloadManager } from "../src/main/download-manager";
|
|
import type { UiSnapshot } from "../src/shared/types";
|
|
|
|
const tempDirs: string[] = [];
|
|
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
|
const legacyManifestField = ["a", "i", "Manifest"].join("");
|
|
const forbiddenSupportMarkers = [
|
|
["A", "I"].join(""),
|
|
["assist", "ant"].join(""),
|
|
["assist", "ants"].join(""),
|
|
["ag", "ent"].join(""),
|
|
["ag", "ents"].join(""),
|
|
["Clau", "de"].join(""),
|
|
["Anthro", "pic"].join(""),
|
|
["Co", "dex"].join(""),
|
|
["Open", "AI"].join(""),
|
|
["M", "C", "P"].join(""),
|
|
["K", "I"].join("")
|
|
];
|
|
|
|
beforeEach(() => {
|
|
configureCredentialProtector({
|
|
isEncryptionAvailable: () => true,
|
|
encryptString: (value) => Buffer.from(value, "utf8").reverse(),
|
|
decryptString: (value) => Buffer.from(value).reverse().toString("utf8")
|
|
});
|
|
});
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
const probe = http.createServer();
|
|
probe.listen(0, "127.0.0.1");
|
|
await once(probe, "listening");
|
|
const address = probe.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("port probe failed");
|
|
}
|
|
probe.close();
|
|
await once(probe, "close");
|
|
return address.port;
|
|
}
|
|
|
|
async function waitForReady(url: string): Promise<void> {
|
|
const deadline = Date.now() + 5000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url, { headers: bearerHeaders("debug-secret") });
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error(`debug server not ready: ${url}`);
|
|
}
|
|
|
|
function bearerHeaders(token: string): HeadersInit {
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
function authedFetch(url: string, token: string, init: RequestInit = {}): Promise<Response> {
|
|
return fetch(url, {
|
|
...init,
|
|
headers: {
|
|
...(init.headers || {}),
|
|
...bearerHeaders(token)
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildSnapshot(baseDir: string): UiSnapshot {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
outputDir: path.join(baseDir, "downloads"),
|
|
extractDir: path.join(baseDir, "extract")
|
|
};
|
|
|
|
const renderer = createRendererState(settings);
|
|
return {
|
|
...renderer,
|
|
session: {
|
|
version: 1,
|
|
packageOrder: ["pkg-1"],
|
|
packages: {
|
|
"pkg-1": {
|
|
id: "pkg-1",
|
|
name: "server-package",
|
|
outputDir: path.join(baseDir, "downloads", "server-package"),
|
|
extractDir: path.join(baseDir, "extract", "server-package"),
|
|
status: "downloading",
|
|
itemIds: ["item-1", "item-2"],
|
|
cancelled: false,
|
|
enabled: true,
|
|
priority: "normal",
|
|
postProcessLabel: "",
|
|
createdAt: Date.now() - 30_000,
|
|
updatedAt: Date.now()
|
|
}
|
|
},
|
|
items: {
|
|
"item-1": {
|
|
id: "item-1",
|
|
packageId: "pkg-1",
|
|
url: "https://hoster.example/file-1",
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid",
|
|
status: "downloading",
|
|
retries: 1,
|
|
speedBps: 8 * 1024 * 1024,
|
|
downloadedBytes: 64 * 1024 * 1024,
|
|
totalBytes: 256 * 1024 * 1024,
|
|
progressPercent: 25,
|
|
fileName: "episode.part1.rar",
|
|
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
|
|
resumable: true,
|
|
attempts: 1,
|
|
lastError: "",
|
|
fullStatus: "Download läuft (Real-Debrid)",
|
|
createdAt: Date.now() - 30_000,
|
|
updatedAt: Date.now()
|
|
},
|
|
"item-2": {
|
|
id: "item-2",
|
|
packageId: "pkg-1",
|
|
url: "https://hoster.example/file-2",
|
|
provider: "realdebrid",
|
|
providerLabel: "Real-Debrid",
|
|
status: "failed",
|
|
retries: 3,
|
|
speedBps: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
progressPercent: 0,
|
|
fileName: "episode.part2.rar",
|
|
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
|
|
resumable: false,
|
|
attempts: 3,
|
|
lastError: "hoster unavailable",
|
|
fullStatus: "Fehler: hoster unavailable",
|
|
createdAt: Date.now() - 30_000,
|
|
updatedAt: Date.now()
|
|
}
|
|
},
|
|
runStartedAt: Date.now() - 30_000,
|
|
totalDownloadedBytes: 64 * 1024 * 1024,
|
|
summaryText: "",
|
|
reconnectUntil: 0,
|
|
reconnectReason: "",
|
|
paused: false,
|
|
running: true,
|
|
updatedAt: Date.now()
|
|
},
|
|
summary: null,
|
|
stats: {
|
|
totalDownloaded: 64 * 1024 * 1024,
|
|
totalDownloadedAllTime: 128 * 1024 * 1024,
|
|
totalFilesSession: 0,
|
|
totalFilesAllTime: 0,
|
|
totalPackages: 1,
|
|
sessionStartedAt: Date.now() - 30_000,
|
|
appSessionStartedAt: Date.now() - 60_000,
|
|
sessionRuntimeMs: 60_000,
|
|
totalRuntimeMs: 3 * 60_000,
|
|
runtimeMeasuredAt: Date.now()
|
|
},
|
|
speedText: "8.0 MB/s",
|
|
etaText: "ETA: 00:25",
|
|
canStart: false,
|
|
canStop: true,
|
|
canPause: true,
|
|
clipboardActive: false,
|
|
reconnectSeconds: 0,
|
|
packageSpeedBps: {
|
|
"pkg-1": 8 * 1024 * 1024
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createFixture() {
|
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
|
|
tempDirs.push(baseDir);
|
|
const token = "debug-secret";
|
|
const port = await getFreePort();
|
|
const snapshot = buildSnapshot(baseDir);
|
|
const storagePaths = createStoragePaths(baseDir);
|
|
|
|
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
|
|
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
|
const debridLinkApiKeys = "key-a\nkey-b";
|
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
|
|
|
|
saveSettings(storagePaths, {
|
|
...defaultSettings(),
|
|
outputDir: snapshot.settings.outputDir,
|
|
extractDir: snapshot.settings.extractDir,
|
|
token: "rd-secret-token",
|
|
realDebridUseWebLogin: true,
|
|
debridLinkApiKeys,
|
|
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
|
|
totalDownloadedAllTime: 128 * 1024 * 1024,
|
|
totalCompletedFilesAllTime: 12,
|
|
totalRuntimeAllTimeMs: 5 * 60_000
|
|
});
|
|
saveHistory(storagePaths, [
|
|
{
|
|
id: "hist-1",
|
|
name: "server-package",
|
|
totalBytes: 123,
|
|
downloadedBytes: 123,
|
|
fileCount: 2,
|
|
provider: "realdebrid",
|
|
completedAt: Date.now() - 5_000,
|
|
durationSeconds: 42,
|
|
status: "completed",
|
|
outputDir: path.join(baseDir, "downloads", "server-package"),
|
|
urls: ["https://hoster.example/file-1"]
|
|
}
|
|
]);
|
|
|
|
configureLogger(baseDir);
|
|
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
|
|
initAuditLog(baseDir);
|
|
const auditLogPath = getAuditLogPath();
|
|
if (!auditLogPath) {
|
|
throw new Error("audit log path missing");
|
|
}
|
|
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
|
|
|
|
initRenameLog(baseDir);
|
|
logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" });
|
|
|
|
initTraceLog(baseDir);
|
|
setTraceEnabled(true, "test-fixture");
|
|
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
|
|
|
|
initSessionLog(baseDir);
|
|
const sessionLogPath = getSessionLogPath();
|
|
if (!sessionLogPath) {
|
|
throw new Error("session log path missing");
|
|
}
|
|
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
|
|
logger.info("TRACE-MAIN-LINE");
|
|
|
|
initPackageLogs(baseDir);
|
|
initItemLogs(baseDir);
|
|
const packageLogPath = ensurePackageLog({
|
|
packageId: "pkg-1",
|
|
name: "server-package",
|
|
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
|
|
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
|
|
});
|
|
if (!packageLogPath) {
|
|
throw new Error("package log path missing");
|
|
}
|
|
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
|
|
const itemLogPath = ensureItemLog({
|
|
itemId: "item-2",
|
|
packageId: "pkg-1",
|
|
packageName: "server-package",
|
|
fileName: "episode.part2.rar",
|
|
targetPath: snapshot.session.items["item-2"]!.targetPath
|
|
});
|
|
if (!itemLogPath) {
|
|
throw new Error("item log path missing");
|
|
}
|
|
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
|
|
|
|
const manager = {
|
|
getSnapshot: () => snapshot,
|
|
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
|
|
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
|
|
} as unknown as DownloadManager;
|
|
|
|
startDebugServer(manager, baseDir);
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
await waitForReady(`${baseUrl}/health`);
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
|
|
return {
|
|
baseUrl,
|
|
token,
|
|
baseDir
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
stopDebugServer();
|
|
shutdownSessionLog();
|
|
shutdownPackageLogs();
|
|
shutdownItemLogs();
|
|
shutdownRenameLog();
|
|
shutdownTraceLog();
|
|
shutdownAuditLog();
|
|
while (tempDirs.length > 0) {
|
|
const dir = tempDirs.pop();
|
|
if (!dir) {
|
|
continue;
|
|
}
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
|
|
describe("debug-server", () => {
|
|
it("serves diagnostics with main, session, and package log tails", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
expect(response.headers.get("cache-control")).toContain("no-store");
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
const payload = await response.json() as Record<string, any>;
|
|
|
|
expect(payload.meta?.appVersion).toBeTruthy();
|
|
expect(payload.meta?.debugServer?.host).toBe("127.0.0.1");
|
|
expect(payload.status?.running).toBe(true);
|
|
expect(payload.host?.platform).toBe("win32");
|
|
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
|
expect(payload.selectedPackage?.name).toBe("server-package");
|
|
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
|
|
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
|
|
expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE");
|
|
expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT");
|
|
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
|
|
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
|
expect(payload.accounts?.realDebrid?.configured).toBe(true);
|
|
expect(payload.history?.total).toBe(1);
|
|
});
|
|
|
|
it("writes a machine-readable support manifest into the runtime folder", async () => {
|
|
const fixture = await createFixture();
|
|
const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
|
|
expect(fs.existsSync(manifestPath)).toBe(true);
|
|
expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false);
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
|
expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
|
|
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
|
|
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
|
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
|
expect(manifest.auth?.methods).toEqual(["Authorization: Bearer <token>"]);
|
|
expect(JSON.stringify(manifest)).not.toContain("?token=");
|
|
expect(manifest.debugServer?.remoteBridgeBaseUrlTemplate).toContain("127.0.0.1");
|
|
expect(manifest.remoteAccessRequirements.join("\n")).toContain("local bridge");
|
|
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
|
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
|
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
|
|
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
|
|
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
|
|
|
const metaResponse = await authedFetch(`${fixture.baseUrl}/meta`, fixture.token);
|
|
expect(metaResponse.ok).toBe(true);
|
|
const metaPayload = await metaResponse.json() as Record<string, any>;
|
|
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
|
|
expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined();
|
|
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
|
|
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
|
|
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
|
|
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
|
|
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
|
|
});
|
|
|
|
it("serves a debug setup check with trace expiry details", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/debug/setup`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
const payload = await response.json() as Record<string, any>;
|
|
|
|
expect(payload.enabled).toBe(true);
|
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
|
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
|
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
|
expect(payload.host).toBe("127.0.0.1");
|
|
expect(payload.localOnly).toBe(true);
|
|
expect(payload.tokenConfigured).toBe(true);
|
|
expect(payload.supportManifestPresent).toBe(true);
|
|
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
|
|
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json"));
|
|
expect(payload.traceEnabled).toBe(true);
|
|
expect(payload.traceAutoDisableAt).toBeTruthy();
|
|
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
|
|
expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0);
|
|
expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0);
|
|
expect(payload.logSummary?.totalBytes).toBeGreaterThan(0);
|
|
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
|
|
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
|
|
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
|
|
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
|
|
expect(JSON.stringify(payload.localUrls)).not.toContain(fixture.token);
|
|
expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain(fixture.token);
|
|
expect(JSON.stringify(payload.localUrls)).not.toContain("?token=");
|
|
expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain("?token=");
|
|
expect(payload.remoteUrlTemplates?.health).toContain("127.0.0.1");
|
|
expect(Array.isArray(payload.notes)).toBe(true);
|
|
});
|
|
|
|
it("serves the self-check alias", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/self-check`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
const payload = await response.json() as Record<string, any>;
|
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
|
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
|
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("writes the client IP into the debug trace log", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token, {
|
|
headers: {
|
|
"X-Forwarded-For": "203.0.113.46"
|
|
}
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
const traceLogPath = getTraceLogPath();
|
|
expect(traceLogPath).toBeTruthy();
|
|
const traceText = fs.readFileSync(traceLogPath!, "utf8");
|
|
expect(traceText).toContain("clientIp=203.0.113.46");
|
|
});
|
|
|
|
it("serves package details and package log by package query", async () => {
|
|
const fixture = await createFixture();
|
|
|
|
const packagesResponse = await authedFetch(`${fixture.baseUrl}/packages?package=server&includeItems=1`, fixture.token);
|
|
expect(packagesResponse.ok).toBe(true);
|
|
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
|
expect(packagesPayload.count).toBe(1);
|
|
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
|
|
|
const logResponse = await authedFetch(`${fixture.baseUrl}/logs/package?package=server-package&lines=20`, fixture.token);
|
|
expect(logResponse.ok).toBe(true);
|
|
const logPayload = await logResponse.json() as Record<string, any>;
|
|
expect(logPayload.package?.name).toBe("server-package");
|
|
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
|
});
|
|
|
|
it("serves item log by item query", async () => {
|
|
const fixture = await createFixture();
|
|
|
|
const response = await authedFetch(`${fixture.baseUrl}/logs/item?item=episode.part2.rar&lines=20`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
const payload = await response.json() as Record<string, any>;
|
|
expect(payload.item?.id).toBe("item-2");
|
|
expect(payload.item?.fileName).toBe("episode.part2.rar");
|
|
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
|
|
});
|
|
|
|
it("serves host diagnostics separately", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/host/diagnostics`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
const payload = await response.json() as Record<string, any>;
|
|
expect(payload.platform).toBe("win32");
|
|
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
|
|
expect(payload.assessmentHints?.[0]).toContain("watchdog");
|
|
});
|
|
|
|
it("serves audit log, settings, accounts, stats, and history", async () => {
|
|
const fixture = await createFixture();
|
|
|
|
const auditResponse = await authedFetch(`${fixture.baseUrl}/logs/audit?lines=20`, fixture.token);
|
|
expect(auditResponse.ok).toBe(true);
|
|
const auditPayload = await auditResponse.json() as Record<string, any>;
|
|
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
|
|
|
|
const renameResponse = await authedFetch(`${fixture.baseUrl}/logs/rename?lines=20`, fixture.token);
|
|
expect(renameResponse.ok).toBe(true);
|
|
const renamePayload = await renameResponse.json() as Record<string, any>;
|
|
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
|
|
|
|
const traceResponse = await authedFetch(`${fixture.baseUrl}/logs/trace?lines=50`, fixture.token);
|
|
expect(traceResponse.ok).toBe(true);
|
|
const tracePayload = await traceResponse.json() as Record<string, any>;
|
|
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
|
|
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
|
|
|
|
const traceConfigGetResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token);
|
|
expect(traceConfigGetResponse.status).toBe(405);
|
|
const traceConfigResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token, { method: "POST" });
|
|
expect(traceConfigResponse.ok).toBe(true);
|
|
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
|
|
expect(traceConfigPayload.config?.enabled).toBe(false);
|
|
|
|
const settingsResponse = await authedFetch(`${fixture.baseUrl}/settings`, fixture.token);
|
|
expect(settingsResponse.ok).toBe(true);
|
|
const settingsPayload = await settingsResponse.json() as Record<string, any>;
|
|
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
|
|
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
|
|
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
|
|
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
|
|
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
|
|
|
|
const accountsResponse = await authedFetch(`${fixture.baseUrl}/accounts`, fixture.token);
|
|
expect(accountsResponse.ok).toBe(true);
|
|
const accountsPayload = await accountsResponse.json() as Record<string, any>;
|
|
expect(accountsPayload.debridLink?.keyCount).toBe(2);
|
|
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
|
|
|
|
const statsResponse = await authedFetch(`${fixture.baseUrl}/stats`, fixture.token);
|
|
expect(statsResponse.ok).toBe(true);
|
|
const statsPayload = await statsResponse.json() as Record<string, any>;
|
|
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
|
|
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
|
|
|
|
const historyResponse = await authedFetch(`${fixture.baseUrl}/history?limit=10`, fixture.token);
|
|
expect(historyResponse.ok).toBe(true);
|
|
const historyPayload = await historyResponse.json() as Record<string, any>;
|
|
expect(historyPayload.total).toBe(1);
|
|
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
|
|
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
|
|
});
|
|
|
|
it("downloads a support bundle zip", async () => {
|
|
const fixture = await createFixture();
|
|
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
|
const response = await authedFetch(`${fixture.baseUrl}/support/bundle`, fixture.token);
|
|
expect(response.ok).toBe(true);
|
|
expect(response.headers.get("content-type")).toContain("application/zip");
|
|
expect(response.headers.get("cache-control")).toContain("no-store");
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const zip = new AdmZip(buffer);
|
|
const entries = zip.getEntries().map((entry) => entry.entryName);
|
|
expect(entries).toContain("overview/settings.json");
|
|
expect(entries).toContain("overview/accounts.json");
|
|
expect(entries).toContain("overview/debug-setup.json");
|
|
expect(entries).not.toContain("overview/self-check.json");
|
|
expect(entries).toContain("overview/trace-config.json");
|
|
expect(entries).toContain("logs/audit.log");
|
|
expect(entries).toContain("logs/rename.log");
|
|
expect(entries).toContain("logs/trace.log");
|
|
expect(entries).toContain("runtime/debug_support_manifest.json");
|
|
expect(entries).toContain("overview/support-manifest.json");
|
|
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
|
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
|
expect(entries).not.toContain("runtime/debug_token.txt");
|
|
});
|
|
|
|
it("rejects unauthenticated requests", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await fetch(`${fixture.baseUrl}/status`);
|
|
expect(response.status).toBe(401);
|
|
expect(response.headers.get("www-authenticate")).toContain("Bearer");
|
|
});
|
|
|
|
it("accepts bearer auth and rejects query tokens", async () => {
|
|
const fixture = await createFixture();
|
|
const bearer = await authedFetch(`${fixture.baseUrl}/health`, fixture.token);
|
|
expect(bearer.status).toBe(200);
|
|
|
|
const queryOnly = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`);
|
|
expect(queryOnly.status).toBe(400);
|
|
const queryOnlyPayload = await queryOnly.json() as Record<string, any>;
|
|
expect(queryOnlyPayload.code).toBe("query_token_rejected");
|
|
|
|
const mixed = await authedFetch(`${fixture.baseUrl}/health?token=bad-fixture-token`, fixture.token);
|
|
expect(mixed.status).toBe(400);
|
|
});
|
|
|
|
it("rejects unsupported methods with a controlled method response", async () => {
|
|
const fixture = await createFixture();
|
|
const response = await authedFetch(`${fixture.baseUrl}/status`, fixture.token, { method: "PUT" });
|
|
expect(response.status).toBe(405);
|
|
expect(response.headers.get("allow")).toContain("GET");
|
|
const payload = await response.json() as Record<string, any>;
|
|
expect(payload.code).toBe("method_not_allowed");
|
|
});
|
|
|
|
it("limits repeated loopback requests with a controlled response", async () => {
|
|
const fixture = await createFixture();
|
|
let limited: Response | null = null;
|
|
for (let i = 0; i < 130; i += 1) {
|
|
const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token);
|
|
if (response.status === 429) {
|
|
limited = response;
|
|
break;
|
|
}
|
|
}
|
|
expect(limited).not.toBeNull();
|
|
expect(limited!.headers.get("retry-after")).toBeTruthy();
|
|
const payload = await limited!.json() as Record<string, any>;
|
|
expect(payload.code).toBe("rate_limited");
|
|
});
|
|
});
|