fix(support): expose notification health aggregates

This commit is contained in:
Sucukdeluxe
2026-08-22 08:00:25 +02:00
parent 16679e73b5
commit 1b8f6fe4b6
7 changed files with 219 additions and 47 deletions
+17 -7
View File
@@ -70,7 +70,7 @@ import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log"; import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
@@ -239,7 +239,7 @@ export class AppController {
} catch (err) { } catch (err) {
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`); logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
} }
startDebugServer(this.manager, this.storagePaths.baseDir); startDebugServer(this.manager, this.storagePaths.baseDir, () => this.getNotificationSupportPayload());
this.runtimeStatsTimer = setInterval(() => { this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats(); this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
@@ -1166,21 +1166,31 @@ export class AppController {
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" }; return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
} }
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
this.audit("INFO", "Support-Bundle exportiert"); this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", { logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length itemCount: Object.keys(this.manager.getSnapshot().session.items).length
}); });
return { return {
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
hostDiagnosticsMode: "cached",
notificationStatus: this.getNotificationSupportPayload()
}),
defaultFileName: getSupportBundleDefaultFileName() defaultFileName: getSupportBundleDefaultFileName()
}; };
} }
public getSupportBundleDefaultFileName(): string { public getSupportBundleDefaultFileName(): string {
return getSupportBundleDefaultFileName(); return getSupportBundleDefaultFileName();
} }
public getNotificationSupportPayload(): NotificationSupportPayload {
return buildNotificationSupportPayload(
this.notificationOutbox.getStatus(),
this.downloadHealthMonitor.getState()
);
}
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } { public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
+33 -15
View File
@@ -12,7 +12,7 @@ import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log"; import { getRenameLogPath } from "./rename-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getConversionLogPath } from "./conversion-trace"; import { getConversionLogPath } from "./conversion-trace";
@@ -56,7 +56,8 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." }, { method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/notifications", description: "Returns safe notification delivery and incident aggregates." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." }, { method: "GET", path: "/status", description: "Returns a live high-level status overview." },
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." }, { method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
@@ -74,6 +75,7 @@ let bindPort = DEFAULT_PORT;
let runtimeBaseDir = ""; let runtimeBaseDir = "";
let allowlist: string[] = []; let allowlist: string[] = [];
let requestLimits = new Map<string, { startedAt: number; count: number }>(); let requestLimits = new Map<string, { startedAt: number; count: number }>();
let notificationStatusProvider: (() => NotificationSupportPayload) | null = null;
export interface DebugServerRuntimeStatus { export interface DebugServerRuntimeStatus {
running: boolean; running: boolean;
@@ -92,9 +94,13 @@ function readSupportSettings() {
return loadSettings(getStoragePaths()); return loadSettings(getStoragePaths());
} }
function readSupportHistory() { function readSupportHistory() {
return loadHistory(getStoragePaths()); return loadHistory(getStoragePaths());
} }
function readNotificationStatus(): NotificationSupportPayload {
return normalizeNotificationSupportPayload(notificationStatusProvider?.());
}
function extractDebugClientIp(req: http.IncomingMessage): string { function extractDebugClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"]; const forwarded = req.headers["x-forwarded-for"];
@@ -908,7 +914,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/stats") { if (pathname === "/stats") {
if (!manager) { if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" }); jsonResponse(res, 503, { error: "Manager not initialized" });
return; return;
@@ -923,8 +929,13 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
} }
}); });
return; return;
} }
if (pathname === "/notifications") {
jsonResponse(res, 200, readNotificationStatus());
return;
}
if (pathname === "/history") { if (pathname === "/history") {
const entries = readSupportHistory(); const entries = readSupportHistory();
@@ -1046,7 +1057,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
const fileName = getSupportBundleDefaultFileName(); const fileName = getSupportBundleDefaultFileName();
buildSupportBundle(manager, runtimeBaseDir) buildSupportBundle(manager, runtimeBaseDir, { notificationStatus: readNotificationStatus() })
.then((body) => { .then((body) => {
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName, fileName,
@@ -1088,7 +1099,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}, },
status: buildStatusPayload(snapshot), status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()), settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot), stats: buildStatsPayload(snapshot),
notifications: readNotificationStatus(),
accounts: buildAccountSummary(readSupportSettings()), accounts: buildAccountSummary(readSupportSettings()),
providers: getProviderRuntimeSnapshot(), providers: getProviderRuntimeSnapshot(),
history: { history: {
@@ -1184,11 +1196,16 @@ function openServerSocket(): Promise<void> {
}); });
} }
export function startDebugServer(mgr: DownloadManager, baseDir: string): void { export function startDebugServer(
runtimeBaseDir = baseDir; mgr: DownloadManager,
manager = mgr; baseDir: string,
void openServerSocket(); readCurrentNotificationStatus?: () => NotificationSupportPayload
} ): void {
runtimeBaseDir = baseDir;
manager = mgr;
notificationStatusProvider = readCurrentNotificationStatus || null;
void openServerSocket();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> { export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server; const old = server;
@@ -1256,6 +1273,7 @@ export function clearDebugToken(): void {
} }
export function stopDebugServer(): void { export function stopDebugServer(): void {
notificationStatusProvider = null;
if (server) { if (server) {
server.close(); server.close();
try { try {
+10 -7
View File
@@ -13,7 +13,7 @@ import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, normalizeNotificationSupportPayload, summarizeHistoryEntry, type NotificationSupportPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
@@ -99,9 +99,10 @@ export function getSupportBundleDefaultFileName(): string {
type HostDiagnosticsMode = "full" | "cached" | "none"; type HostDiagnosticsMode = "full" | "cached" | "none";
interface BuildSupportBundleOptions { interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode; hostDiagnosticsMode?: HostDiagnosticsMode;
} notificationStatus?: NotificationSupportPayload;
}
function createDeferredHostDiagnostics(reason: string): unknown { function createDeferredHostDiagnostics(reason: string): unknown {
return { return {
@@ -144,7 +145,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages); const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items); const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir); const debugSetup = getDebugSetupCheck(baseDir);
const notificationStatus = normalizeNotificationSupportPayload(options.notificationStatus);
addJson(zip, "overview/meta.json", { addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION, appVersion: APP_VERSION,
@@ -156,14 +158,15 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
addJson(zip, "overview/status.json", snapshot.session); addJson(zip, "overview/status.json", snapshot.session);
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
addJson(zip, "overview/stats.json", { addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot), ...buildStatsPayload(snapshot),
allTime: { allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime, totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
} }
}); });
addJson(zip, "overview/notifications.json", notificationStatus);
addJson(zip, "overview/debug-setup.json", debugSetup); addJson(zip, "overview/debug-setup.json", debugSetup);
addJson(zip, "overview/self-check.json", debugSetup); addJson(zip, "overview/self-check.json", debugSetup);
addJson(zip, "overview/history.json", { addJson(zip, "overview/history.json", {
+22 -1
View File
@@ -16,6 +16,27 @@ export interface NotificationSupportPayload {
incidentAgeMs: number | null; incidentAgeMs: number | null;
} }
export function normalizeNotificationSupportPayload(
value?: Partial<NotificationSupportPayload> | null
): NotificationSupportPayload {
const rawQueued = value?.queued;
const rawLastSuccessAt = value?.lastSuccessAt;
const rawIncidentAgeMs = value?.incidentAgeMs;
const queued = typeof rawQueued === "number" && Number.isFinite(rawQueued)
? Math.max(0, Math.floor(rawQueued))
: 0;
const lastSuccessAt = typeof rawLastSuccessAt === "number" && Number.isFinite(rawLastSuccessAt) && rawLastSuccessAt > 0
? Math.floor(rawLastSuccessAt)
: null;
const incidentType = value?.incidentType === "scheduler" || value?.incidentType === "no_data"
? value.incidentType
: null;
const incidentAgeMs = incidentType && typeof rawIncidentAgeMs === "number" && Number.isFinite(rawIncidentAgeMs) && rawIncidentAgeMs >= 0
? Math.floor(rawIncidentAgeMs)
: null;
return { queued, lastSuccessAt, incidentType, incidentAgeMs };
}
export function buildNotificationSupportPayload( export function buildNotificationSupportPayload(
outbox: Pick<NotificationOutboxStatus, "queued" | "lastSuccessAt">, outbox: Pick<NotificationOutboxStatus, "queued" | "lastSuccessAt">,
health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">, health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">,
@@ -34,7 +55,7 @@ export function buildNotificationSupportPayload(
const incidentAgeMs = incidentType && incidentStartedAt > 0 const incidentAgeMs = incidentType && incidentStartedAt > 0
? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt) ? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt)
: null; : null;
return { queued, lastSuccessAt, incidentType, incidentAgeMs }; return normalizeNotificationSupportPayload({ queued, lastSuccessAt, incidentType, incidentAgeMs });
} }
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> { export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
+59 -9
View File
@@ -53,7 +53,8 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log"; import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager"; import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
import type { UiSnapshot } from "../src/shared/types"; import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -326,15 +327,25 @@ async function createFixture() {
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager; } as unknown as DownloadManager;
startDebugServer(manager, baseDir); const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000,
events: [{ payload: "PRIVATE_DEBUG_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
startDebugServer(manager, baseDir, () => notificationStatus);
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health`); await waitForReady(`${baseUrl}/health`);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
return { return {
baseUrl, baseUrl,
token, token,
baseDir baseDir,
notificationStatus
}; };
} }
@@ -359,6 +370,36 @@ afterEach(() => {
}); });
describe("debug-server", () => { describe("debug-server", () => {
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, unknown>;
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
fixture.notificationStatus.queued = 9;
fixture.notificationStatus.lastSuccessAt = 1_700_000_060_000;
const currentResponse = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
const currentPayload = await currentResponse.json() as Record<string, unknown>;
expect(currentPayload).toEqual({
queued: 9,
lastSuccessAt: 1_700_000_060_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
const diagnosticsResponse = await authedFetch(`${fixture.baseUrl}/diagnostics`, fixture.token);
const diagnostics = await diagnosticsResponse.json() as Record<string, any>;
expect(diagnostics.notifications).toEqual(currentPayload);
expect(JSON.stringify([payload, currentPayload, diagnostics.notifications])).not.toMatch(/PRIVATE_|https:\/\/private|@private|events|payload/i);
});
it("serves diagnostics with main, session, and package log tails", async () => { it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token); const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
@@ -584,7 +625,8 @@ describe("debug-server", () => {
expect(entries).toContain("overview/accounts.json"); expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json"); expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json"); expect(entries).toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json"); expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("overview/notifications.json");
expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log"); expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log"); expect(entries).toContain("logs/trace.log");
@@ -592,8 +634,16 @@ describe("debug-server", () => {
expect(entries).toContain("overview/support-manifest.json"); expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`); expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join("")); expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
expect(entries).not.toContain("runtime/debug_token.txt"); expect(entries).not.toContain("runtime/debug_token.txt");
}); const notifications = JSON.parse(zip.getEntry("overview/notifications.json")?.getData().toString("utf8") || "null");
expect(notifications).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(buffer.toString("utf8")).not.toMatch(/PRIVATE_DEBUG_EVENT_PAYLOAD|https:\/\/private|@private/);
});
it("rejects unauthenticated requests", async () => { it("rejects unauthenticated requests", async () => {
const fixture = await createFixture(); const fixture = await createFixture();
+42 -7
View File
@@ -2,9 +2,11 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle"; import { buildSupportBundle } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager"; import { createStoragePaths } from "../src/main/storage";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
@@ -60,7 +62,7 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
}); });
it("does not block the event loop while building (a concurrent timer still fires)", async () => { it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root); tempDirs.push(root);
@@ -69,6 +71,39 @@ describe("buildSupportBundle (async, non-blocking)", () => {
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer); clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true); expect(timerFired).toBe(true);
}); });
});
it("writes only the safe notification aggregate and excludes its runtime files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-notifications-"));
tempDirs.push(root);
const paths = createStoragePaths(root);
fs.writeFileSync(paths.notificationOutboxFile, "PRIVATE_OUTBOX_RUNTIME_PAYLOAD", "utf8");
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_RUNTIME_PAYLOAD", "utf8");
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000,
events: [{ payload: "PRIVATE_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", notificationStatus });
const zip = new AdmZip(buffer);
const entry = zip.getEntry("overview/notifications.json");
const payload = JSON.parse(entry?.getData().toString("utf8") || "null");
const serialized = buffer.toString("utf8");
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000
});
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationOutboxFile));
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationHealthFile));
expect(serialized).not.toMatch(/PRIVATE_|https:\/\/private|@private/);
});
});
+36 -1
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data"; import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
import { buildSupportBundle } from "../src/main/support-bundle"; import { buildSupportBundle } from "../src/main/support-bundle";
@@ -11,6 +12,40 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
import { createVisualFixture } from "./visual/fixtures"; import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => { describe("Real-Debrid support summary", () => {
it("projects the current private AppController notification state into the safe DTO", () => {
const controller = Object.create(AppController.prototype) as AppController;
const internals = controller as unknown as {
notificationOutbox: { getStatus: () => Record<string, unknown> };
downloadHealthMonitor: { getState: () => Record<string, unknown> };
};
internals.notificationOutbox = {
getStatus: () => ({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: "PRIVATE_CONTROLLER_EVENT" }]
})
};
internals.downloadHealthMonitor = {
getState: () => ({
incidentType: "scheduler",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "PRIVATE_CONTROLLER_FINGERPRINT"
})
};
vi.spyOn(Date, "now").mockReturnValue(1_700_000_050_000);
const payload = controller.getNotificationSupportPayload();
expect(payload).toEqual({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/PRIVATE_|event|payload|fingerprint|lastFailure/i);
vi.restoreAllMocks();
});
it("projects only safe notification delivery and incident aggregates", () => { it("projects only safe notification delivery and incident aggregates", () => {
const payload = buildNotificationSupportPayload( const payload = buildNotificationSupportPayload(
{ {