diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 89ac69f..52946bb 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -70,7 +70,7 @@ import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./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 { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; @@ -239,7 +239,7 @@ export class AppController { } catch (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.manager.persistRuntimeStats(); this.settings = this.manager.getSettings(); @@ -1166,21 +1166,31 @@ export class AppController { 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"); logTraceEvent("INFO", "support", "Support-Bundle erstellt", { packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, itemCount: Object.keys(this.manager.getSnapshot().session.items).length }); 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() }; } - public getSupportBundleDefaultFileName(): string { - return getSupportBundleDefaultFileName(); - } + public getSupportBundleDefaultFileName(): string { + 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 } { let parsed: Record; diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 6806389..e04a8b1 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -12,7 +12,7 @@ import { getSessionLogPath } from "./session-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; import { getRenameLogPath } from "./rename-log"; 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 { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; 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: "/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: "/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: "/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." }, @@ -74,6 +75,7 @@ let bindPort = DEFAULT_PORT; let runtimeBaseDir = ""; let allowlist: string[] = []; let requestLimits = new Map(); +let notificationStatusProvider: (() => NotificationSupportPayload) | null = null; export interface DebugServerRuntimeStatus { running: boolean; @@ -92,9 +94,13 @@ function readSupportSettings() { return loadSettings(getStoragePaths()); } -function readSupportHistory() { - return loadHistory(getStoragePaths()); -} +function readSupportHistory() { + return loadHistory(getStoragePaths()); +} + +function readNotificationStatus(): NotificationSupportPayload { + return normalizeNotificationSupportPayload(notificationStatusProvider?.()); +} function extractDebugClientIp(req: http.IncomingMessage): string { const forwarded = req.headers["x-forwarded-for"]; @@ -908,7 +914,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } - if (pathname === "/stats") { + if (pathname === "/stats") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); return; @@ -923,8 +929,13 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs } }); - return; - } + return; + } + + if (pathname === "/notifications") { + jsonResponse(res, 200, readNotificationStatus()); + return; + } if (pathname === "/history") { const entries = readSupportHistory(); @@ -1046,7 +1057,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } const fileName = getSupportBundleDefaultFileName(); - buildSupportBundle(manager, runtimeBaseDir) + buildSupportBundle(manager, runtimeBaseDir, { notificationStatus: readNotificationStatus() }) .then((body) => { logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", { fileName, @@ -1088,7 +1099,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi }, status: buildStatusPayload(snapshot), settings: buildRedactedSettingsPayload(readSupportSettings()), - stats: buildStatsPayload(snapshot), + stats: buildStatsPayload(snapshot), + notifications: readNotificationStatus(), accounts: buildAccountSummary(readSupportSettings()), providers: getProviderRuntimeSnapshot(), history: { @@ -1184,11 +1196,16 @@ function openServerSocket(): Promise { }); } -export function startDebugServer(mgr: DownloadManager, baseDir: string): void { - runtimeBaseDir = baseDir; - manager = mgr; - void openServerSocket(); -} +export function startDebugServer( + mgr: DownloadManager, + baseDir: string, + readCurrentNotificationStatus?: () => NotificationSupportPayload +): void { + runtimeBaseDir = baseDir; + manager = mgr; + notificationStatusProvider = readCurrentNotificationStatus || null; + void openServerSocket(); +} export async function restartDebugServer(): Promise { const old = server; @@ -1256,6 +1273,7 @@ export function clearDebugToken(): void { } export function stopDebugServer(): void { + notificationStatusProvider = null; if (server) { server.close(); try { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 4180792..37b260a 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -13,7 +13,7 @@ import { getRenameLogPath } from "./rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getSessionLogPath } from "./session-log"; 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 { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; @@ -99,9 +99,10 @@ export function getSupportBundleDefaultFileName(): string { type HostDiagnosticsMode = "full" | "cached" | "none"; -interface BuildSupportBundleOptions { - hostDiagnosticsMode?: HostDiagnosticsMode; -} +interface BuildSupportBundleOptions { + hostDiagnosticsMode?: HostDiagnosticsMode; + notificationStatus?: NotificationSupportPayload; +} function createDeferredHostDiagnostics(reason: string): unknown { return { @@ -144,7 +145,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri const snapshot = manager.getSnapshot(); const packageIds = Object.keys(snapshot.session.packages); 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", { 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/settings.json", buildRedactedSettingsPayload(settings)); addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); - addJson(zip, "overview/stats.json", { + addJson(zip, "overview/stats.json", { ...buildStatsPayload(snapshot), allTime: { totalDownloadedAllTime: settings.totalDownloadedAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs } - }); + }); + addJson(zip, "overview/notifications.json", notificationStatus); addJson(zip, "overview/debug-setup.json", debugSetup); addJson(zip, "overview/self-check.json", debugSetup); addJson(zip, "overview/history.json", { diff --git a/src/main/support-data.ts b/src/main/support-data.ts index 6c506be..d493ebb 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -16,6 +16,27 @@ export interface NotificationSupportPayload { incidentAgeMs: number | null; } +export function normalizeNotificationSupportPayload( + value?: Partial | 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( outbox: Pick, health: Pick, @@ -34,7 +55,7 @@ export function buildNotificationSupportPayload( const incidentAgeMs = incidentType && incidentStartedAt > 0 ? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt) : null; - return { queued, lastSuccessAt, incidentType, incidentAgeMs }; + return normalizeNotificationSupportPayload({ queued, lastSuccessAt, incidentType, incidentAgeMs }); } export function buildAccountSummary(settings: AppSettings): Record { diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index a4e4e16..fbda6b3 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -53,7 +53,8 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma 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 { DownloadManager } from "../src/main/download-manager"; +import type { NotificationSupportPayload } from "../src/main/support-data"; import type { UiSnapshot } from "../src/shared/types"; const tempDirs: string[] = []; @@ -326,15 +327,25 @@ async function createFixture() { getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null } as unknown as DownloadManager; - startDebugServer(manager, baseDir); + const notificationStatus: NotificationSupportPayload & Record = { + 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}`; await waitForReady(`${baseUrl}/health`); await new Promise((resolve) => setTimeout(resolve, 300)); - return { - baseUrl, - token, - baseDir + return { + baseUrl, + token, + baseDir, + notificationStatus }; } @@ -359,6 +370,36 @@ afterEach(() => { }); 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; + + 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; + 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; + 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 () => { const fixture = await createFixture(); 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/debug-setup.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/rename.log"); expect(entries).toContain("logs/trace.log"); @@ -592,8 +634,16 @@ describe("debug-server", () => { 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"); - }); + 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 () => { const fixture = await createFixture(); diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts index bc3cf31..922ff6a 100644 --- a/tests/support-bundle.test.ts +++ b/tests/support-bundle.test.ts @@ -2,9 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import AdmZip from "adm-zip"; -import { afterEach, describe, expect, it } from "vitest"; -import { buildSupportBundle } from "../src/main/support-bundle"; -import type { DownloadManager } from "../src/main/download-manager"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSupportBundle } from "../src/main/support-bundle"; +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 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"); }); - 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-")); tempDirs.push(root); @@ -69,6 +71,39 @@ describe("buildSupportBundle (async, non-blocking)", () => { await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); clearTimeout(timer); 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 = { + 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/); + }); +}); diff --git a/tests/support-data.test.ts b/tests/support-data.test.ts index 84eaf40..c8b1ace 100644 --- a/tests/support-data.test.ts +++ b/tests/support-data.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; 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 { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data"; import { buildSupportBundle } from "../src/main/support-bundle"; @@ -11,6 +12,40 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun import { createVisualFixture } from "./visual/fixtures"; 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 }; + downloadHealthMonitor: { getState: () => Record }; + }; + 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", () => { const payload = buildNotificationSupportPayload( {