feat(realdebrid): isolate browser account sessions
This commit is contained in:
+203
-29
@@ -38,7 +38,8 @@ import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, che
|
||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||
import { getRealDebridAccounts, isRealDebridWebAccountId, type RealDebridWebAccountEntry } from "../shared/real-debrid-accounts";
|
||||
import type { RealDebridLoginRequest } from "../shared/preload-api";
|
||||
import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands";
|
||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
|
||||
import { createRendererState } from "./renderer-state";
|
||||
@@ -91,7 +92,13 @@ export class AppController {
|
||||
|
||||
private megaWebFallback: MegaWebFallback;
|
||||
|
||||
private realDebridWebFallback: RealDebridWebFallback;
|
||||
private realDebridWebFallbacks = new Map<string, RealDebridWebFallback>();
|
||||
|
||||
private pendingRealDebridWebAccountIds = new Map<string, number>();
|
||||
|
||||
private realDebridWebGenerations = new Map<string, number>();
|
||||
|
||||
private realDebridWebAuthenticationTasks = new Map<string, Promise<void>>();
|
||||
|
||||
private allDebridWebFallback: AllDebridWebFallback;
|
||||
|
||||
@@ -139,16 +146,12 @@ export class AppController {
|
||||
login: this.settings.megaLogin,
|
||||
password: this.settings.megaPassword
|
||||
}));
|
||||
this.realDebridWebFallback = new RealDebridWebFallback(
|
||||
() => this.settings.rememberToken,
|
||||
() => { void this.refreshRealDebridWebStatus(); }
|
||||
);
|
||||
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||||
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
|
||||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.unrestrictWithFirstRealDebridWebAccount(link, signal),
|
||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||
@@ -497,10 +500,14 @@ export class AppController {
|
||||
changedKeys: Object.keys(sanitizedPatch),
|
||||
accountChanges: diffAccountSummary(previousSettings, this.settings)
|
||||
});
|
||||
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
||||
void this.realDebridWebFallback.clearSessions().catch((error) => {
|
||||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||
});
|
||||
this.pruneRealDebridWebFallbacks(previousSettings, this.settings);
|
||||
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
||||
const accountIds = new Set([...this.settings.realDebridWebAccountIds, ...this.realDebridWebFallbacks.keys()]);
|
||||
for (const accountId of accountIds) {
|
||||
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
||||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
|
||||
});
|
||||
}
|
||||
void this.allDebridWebFallback.clearSessions().catch((error) => {
|
||||
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||
});
|
||||
@@ -571,7 +578,7 @@ export class AppController {
|
||||
account,
|
||||
undefined,
|
||||
Date.now(),
|
||||
useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined
|
||||
useWebLogin ? (signal) => this.getRealDebridWebFallback(account.id).probeLoginState(signal) : undefined
|
||||
),
|
||||
redactions
|
||||
);
|
||||
@@ -610,7 +617,7 @@ export class AppController {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
||||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
const nextSettings = normalizeSettings({
|
||||
...liveSettings,
|
||||
@@ -620,29 +627,192 @@ export class AppController {
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public async openRealDebridLoginWindow(): Promise<void> {
|
||||
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
|
||||
await this.realDebridWebFallback.openLoginWindow();
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private async refreshRealDebridWebStatus(): Promise<void> {
|
||||
const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web");
|
||||
if (!account) {
|
||||
private getRealDebridWebPartition(accountId: string): string {
|
||||
return accountId === "rdw_legacy"
|
||||
? "persist:realdebrid-web"
|
||||
: `persist:realdebrid-web-${accountId}`;
|
||||
}
|
||||
|
||||
private getRealDebridWebFallback(accountId: string): RealDebridWebFallback {
|
||||
if (!isRealDebridWebAccountId(accountId)) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
}
|
||||
const existing = this.realDebridWebFallbacks.get(accountId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const fallback = new RealDebridWebFallback(
|
||||
this.getRealDebridWebPartition(accountId),
|
||||
() => this.settings.rememberToken,
|
||||
() => this.queueRealDebridWebAuthentication(accountId),
|
||||
() => this.handleRealDebridWebWindowClosed(accountId)
|
||||
);
|
||||
this.realDebridWebFallbacks.set(accountId, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private queueRealDebridWebAuthentication(accountId: string): void {
|
||||
if (this.realDebridWebAuthenticationTasks.has(accountId)) {
|
||||
return;
|
||||
}
|
||||
const generation = this.realDebridWebGenerations.get(accountId) || 0;
|
||||
const task = this.refreshRealDebridWebStatus(accountId, generation)
|
||||
.catch((error) => logger.warn(`Real-Debrid Web-Status konnte nicht aktualisiert werden (${accountId}): ${String(error)}`))
|
||||
.finally(() => {
|
||||
if (this.realDebridWebAuthenticationTasks.get(accountId) === task) {
|
||||
this.realDebridWebAuthenticationTasks.delete(accountId);
|
||||
}
|
||||
});
|
||||
this.realDebridWebAuthenticationTasks.set(accountId, task);
|
||||
}
|
||||
|
||||
private handleRealDebridWebWindowClosed(accountId: string): void {
|
||||
if (!this.pendingRealDebridWebAccountIds.has(accountId)) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve().then(async () => {
|
||||
const authenticationTask = this.realDebridWebAuthenticationTasks.get(accountId);
|
||||
if (authenticationTask) {
|
||||
await authenticationTask;
|
||||
}
|
||||
if (!this.pendingRealDebridWebAccountIds.has(accountId)
|
||||
|| this.settings.realDebridWebAccountIds.includes(accountId)) {
|
||||
return;
|
||||
}
|
||||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||||
}).catch((error) => {
|
||||
logger.warn(`Abgebrochener Real-Debrid Web-Login konnte nicht bereinigt werden (${accountId}): ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanupRealDebridWebAccount(accountId: string, clearStorage: boolean): Promise<void> {
|
||||
const nextGeneration = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
|
||||
this.realDebridWebGenerations.set(accountId, nextGeneration);
|
||||
this.pendingRealDebridWebAccountIds.delete(accountId);
|
||||
const existing = this.realDebridWebFallbacks.get(accountId);
|
||||
this.realDebridWebFallbacks.delete(accountId);
|
||||
const fallback = existing || new RealDebridWebFallback(
|
||||
this.getRealDebridWebPartition(accountId),
|
||||
() => this.settings.rememberToken
|
||||
);
|
||||
if (clearStorage) {
|
||||
await fallback.clearSessions();
|
||||
} else {
|
||||
fallback.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
|
||||
const currentIds = new Set(current.realDebridWebAccountIds);
|
||||
for (const accountId of previous.realDebridWebAccountIds) {
|
||||
if (currentIds.has(accountId)) {
|
||||
continue;
|
||||
}
|
||||
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
||||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async unrestrictWithFirstRealDebridWebAccount(link: string, signal?: AbortSignal) {
|
||||
const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web" && entry.enabled);
|
||||
return account ? this.unrestrictRealDebridWebAccount(account.id, link, signal) : null;
|
||||
}
|
||||
|
||||
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
||||
const accountId = String(request.accountId || "").trim();
|
||||
if (!isRealDebridWebAccountId(accountId)) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
}
|
||||
const existing = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
|
||||
if (request.create && existing) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
}
|
||||
if (!request.create && !existing && accountId !== "rdw_legacy") {
|
||||
throw new Error("Account wurde nicht gefunden");
|
||||
}
|
||||
if (!existing) {
|
||||
const generation = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
|
||||
this.realDebridWebGenerations.set(accountId, generation);
|
||||
this.pendingRealDebridWebAccountIds.set(accountId, generation);
|
||||
}
|
||||
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet", { accountId, create: !existing });
|
||||
try {
|
||||
await this.getRealDebridWebFallback(accountId).openLoginWindow();
|
||||
} catch (error) {
|
||||
if (!existing) {
|
||||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public probeRealDebridWebAccount(accountId: string, signal?: AbortSignal) {
|
||||
return this.getRealDebridWebFallback(accountId).probeLoginState(signal);
|
||||
}
|
||||
|
||||
public unrestrictRealDebridWebAccount(accountId: string, link: string, signal?: AbortSignal) {
|
||||
return this.getRealDebridWebFallback(accountId).unrestrict(link, signal);
|
||||
}
|
||||
|
||||
public async clearRealDebridWebAccount(accountId: string): Promise<void> {
|
||||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||||
}
|
||||
|
||||
private async refreshRealDebridWebStatus(accountId: string, generation = this.realDebridWebGenerations.get(accountId) || 0): Promise<void> {
|
||||
const account = getRealDebridAccounts(this.settings)
|
||||
.filter((entry): entry is RealDebridWebAccountEntry => entry.kind === "web")
|
||||
.find((entry) => entry.id === accountId);
|
||||
const checkedAccount: RealDebridWebAccountEntry = account || {
|
||||
id: accountId,
|
||||
kind: "web",
|
||||
index: this.settings.realDebridWebAccountIds.length,
|
||||
label: `Browser-Login ${this.settings.realDebridWebAccountIds.length + 1}`,
|
||||
maskedLogin: "Geschützter Browser-Login",
|
||||
enabled: true
|
||||
};
|
||||
const status = sanitizeDebridAccountStatus(
|
||||
await checkRealDebridAccount(
|
||||
account,
|
||||
checkedAccount,
|
||||
undefined,
|
||||
Date.now(),
|
||||
(signal) => this.realDebridWebFallback.probeLoginState(signal)
|
||||
(signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal)
|
||||
),
|
||||
collectAccountStatusRedactionValues(this.settings)
|
||||
);
|
||||
if (!status.valid) {
|
||||
return;
|
||||
}
|
||||
if ((this.realDebridWebGenerations.get(accountId) || 0) !== generation) {
|
||||
return;
|
||||
}
|
||||
const currentAccount = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
|
||||
if (account && !currentAccount) {
|
||||
return;
|
||||
}
|
||||
if (!account) {
|
||||
if (this.pendingRealDebridWebAccountIds.get(accountId) !== generation) {
|
||||
return;
|
||||
}
|
||||
const applied = applyAccountCommand(this.settings, {
|
||||
action: "create",
|
||||
kind: "realdebrid-web",
|
||||
identity: accountId,
|
||||
secret: "",
|
||||
dailyLimitBytes: 0
|
||||
});
|
||||
this.pendingRealDebridWebAccountIds.delete(accountId);
|
||||
this.updateSettings(applied.settings);
|
||||
}
|
||||
this.manager.applyDebridAccountStatuses([status]);
|
||||
const fallback = this.realDebridWebFallbacks.get(accountId);
|
||||
if (fallback) {
|
||||
this.realDebridWebFallbacks.delete(accountId);
|
||||
fallback.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async openAllDebridLoginWindow(): Promise<void> {
|
||||
@@ -679,7 +849,7 @@ export class AppController {
|
||||
await checkAllDebridAccounts(
|
||||
this.settings,
|
||||
undefined,
|
||||
(_accountId, signal) => this.realDebridWebFallback.probeLoginState(signal),
|
||||
(accountId, signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal),
|
||||
scope
|
||||
),
|
||||
collectAccountStatusRedactionValues(this.settings)
|
||||
@@ -1075,10 +1245,14 @@ export class AppController {
|
||||
stopDebugServer();
|
||||
abortActiveUpdateDownload();
|
||||
cancelPendingAsyncSaves();
|
||||
this.manager.prepareForShutdown();
|
||||
this.megaWebFallback.dispose();
|
||||
this.realDebridWebFallback.dispose();
|
||||
this.allDebridWebFallback.dispose();
|
||||
this.manager.prepareForShutdown();
|
||||
this.megaWebFallback.dispose();
|
||||
for (const fallback of this.realDebridWebFallbacks.values()) {
|
||||
fallback.dispose();
|
||||
}
|
||||
this.realDebridWebFallbacks.clear();
|
||||
this.pendingRealDebridWebAccountIds.clear();
|
||||
this.allDebridWebFallback.dispose();
|
||||
this.bestDebridWebFallback.dispose();
|
||||
this.shutdownLogStorage();
|
||||
this.audit("INFO", "App beendet");
|
||||
|
||||
+4
-3
@@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state";
|
||||
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { validateRealDebridLoginRequest } from "../shared/preload-api";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -815,9 +816,9 @@ function registerIpcHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
||||
await controller.openRealDebridLoginWindow();
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
|
||||
await controller.openRealDebridLoginWindow(validateRealDebridLoginRequest(rawRequest));
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
||||
await controller.openAllDebridLoginWindow();
|
||||
|
||||
+108
-49
@@ -9,8 +9,7 @@ const RD_LOGIN_URL = RD_BASE_URL;
|
||||
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
||||
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
|
||||
const RD_USER_API = `${API_BASE_URL}/user`;
|
||||
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||
const RD_TRANSIENT_PARTITION = "realdebrid-web";
|
||||
const RD_PARTITION_PATTERN = /^persist:realdebrid-web(?:-rdw_[A-Za-z0-9_-]{1,96})?$/;
|
||||
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
type GenerateOutcome =
|
||||
@@ -134,12 +133,32 @@ export class RealDebridWebFallback {
|
||||
|
||||
private onAuthenticated?: () => void;
|
||||
|
||||
public constructor(getRememberSession: () => boolean, onAuthenticated?: () => void) {
|
||||
private onClosed?: () => void;
|
||||
|
||||
private persistentPartition: string;
|
||||
|
||||
private transientPartition: string;
|
||||
|
||||
private lifecycleGeneration = 0;
|
||||
|
||||
private programmaticClosures = new WeakSet<BrowserWindow>();
|
||||
|
||||
private disposed = false;
|
||||
|
||||
public constructor(partition: string, getRememberSession: () => boolean, onAuthenticated?: () => void, onClosed?: () => void) {
|
||||
const normalizedPartition = String(partition || "").trim();
|
||||
if (!RD_PARTITION_PATTERN.test(normalizedPartition)) {
|
||||
throw new Error("Real-Debrid Web-Partition ist ungültig");
|
||||
}
|
||||
this.persistentPartition = normalizedPartition;
|
||||
this.transientPartition = normalizedPartition.slice("persist:".length);
|
||||
this.getRememberSession = getRememberSession;
|
||||
this.onAuthenticated = onAuthenticated;
|
||||
this.onClosed = onClosed;
|
||||
}
|
||||
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||
this.throwIfDisposed();
|
||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
||||
return this.runExclusive(async () => {
|
||||
throwIfAborted(overallSignal);
|
||||
@@ -156,6 +175,7 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
|
||||
public async openLoginWindow(): Promise<void> {
|
||||
this.throwIfDisposed();
|
||||
const window = await this.ensureLoginWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
@@ -166,6 +186,7 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
|
||||
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
|
||||
this.throwIfDisposed();
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await this.extractApiToken(signal);
|
||||
@@ -219,11 +240,12 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposeLoginWindow();
|
||||
this.cachedToken = "";
|
||||
public async clearSessions(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.disposeLoginWindow();
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
|
||||
for (const partition of [this.persistentPartition, this.transientPartition]) {
|
||||
const currentSession = session.fromPartition(partition);
|
||||
try {
|
||||
await currentSession.clearStorageData({
|
||||
@@ -238,21 +260,32 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeLoginWindow();
|
||||
public dispose(): void {
|
||||
this.disposed = true;
|
||||
this.disposeLoginWindow();
|
||||
this.cachedToken = "";
|
||||
this.cachedTokenAt = 0;
|
||||
}
|
||||
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
|
||||
}
|
||||
private getPartition(): string {
|
||||
return this.getRememberSession() ? this.persistentPartition : this.transientPartition;
|
||||
}
|
||||
|
||||
private throwIfDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error("Real-Debrid Web-Sitzung wurde geschlossen");
|
||||
}
|
||||
}
|
||||
|
||||
private disposeLoginWindow(): void {
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
current.close();
|
||||
}
|
||||
private disposeLoginWindow(): void {
|
||||
this.lifecycleGeneration += 1;
|
||||
const current = this.loginWindow;
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
if (current && !current.isDestroyed()) {
|
||||
this.programmaticClosures.add(current);
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
@@ -297,28 +330,45 @@ export class RealDebridWebFallback {
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||
const primeFromWindow = (): void => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
const windowGeneration = this.lifecycleGeneration;
|
||||
const primeFromWindow = (): void => {
|
||||
void this.primeTokenFromWindow(window, windowGeneration);
|
||||
};
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", primeFromWindow);
|
||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||
window.on("close", () => {
|
||||
void this.primeTokenFromWindow(window);
|
||||
});
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
});
|
||||
window.webContents.on("did-finish-load", primeFromWindow);
|
||||
window.webContents.on("did-navigate", primeFromWindow);
|
||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||
let closingTokenProbe: Promise<void> = Promise.resolve();
|
||||
window.on("close", () => {
|
||||
if (!this.programmaticClosures.has(window)) {
|
||||
closingTokenProbe = this.primeTokenFromWindow(window, windowGeneration);
|
||||
}
|
||||
});
|
||||
window.on("closed", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
this.loginWindowPartition = "";
|
||||
}
|
||||
if (!this.programmaticClosures.has(window)) {
|
||||
void closingTokenProbe.finally(() => this.onClosed?.());
|
||||
}
|
||||
});
|
||||
this.loginWindow = window;
|
||||
this.loginWindowPartition = partition;
|
||||
await window.loadURL(RD_LOGIN_URL);
|
||||
return window;
|
||||
try {
|
||||
await window.loadURL(RD_LOGIN_URL);
|
||||
} catch (error) {
|
||||
if (this.loginWindow === window) {
|
||||
this.disposeLoginWindow();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private rememberToken(token: string): string {
|
||||
private rememberToken(token: string, generation = this.lifecycleGeneration): string | null {
|
||||
if (this.disposed || generation !== this.lifecycleGeneration) {
|
||||
return null;
|
||||
}
|
||||
const changed = token !== this.cachedToken;
|
||||
this.cachedToken = token;
|
||||
this.cachedTokenAt = Date.now();
|
||||
@@ -339,7 +389,7 @@ export class RealDebridWebFallback {
|
||||
return window;
|
||||
}
|
||||
|
||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal, generation = this.lifecycleGeneration): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
try {
|
||||
@@ -390,8 +440,8 @@ export class RealDebridWebFallback {
|
||||
})();
|
||||
`, true);
|
||||
const token = String(rawResult || "").trim();
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
if (token && generation === this.lifecycleGeneration && !this.programmaticClosures.has(window)) {
|
||||
return this.rememberToken(token, generation);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
@@ -399,15 +449,20 @@ export class RealDebridWebFallback {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window);
|
||||
private async primeTokenFromWindow(window: BrowserWindow, generation = this.lifecycleGeneration): Promise<void> {
|
||||
try {
|
||||
await this.extractApiTokenFromWindow(window, undefined, generation);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||
throwIfAborted(signal);
|
||||
const generation = this.lifecycleGeneration;
|
||||
|
||||
if (this.disposed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||
return this.cachedToken;
|
||||
@@ -430,15 +485,19 @@ export class RealDebridWebFallback {
|
||||
},
|
||||
signal: withTimeoutSignal(signal, 30_000)
|
||||
});
|
||||
const html = await response.text();
|
||||
const html = await response.text();
|
||||
|
||||
if (this.disposed || generation !== this.lifecycleGeneration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token, generation);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress
|
||||
} from "../shared/types";
|
||||
} from "../shared/types";
|
||||
import type { RealDebridLoginRequest } from "../shared/preload-api";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { ElectronApi } from "../shared/preload-api";
|
||||
|
||||
@@ -101,7 +102,7 @@ const api: ElectronApi = {
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||
openRealDebridLogin: (request: RealDebridLoginRequest = { accountId: "rdw_legacy" }): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, request),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
|
||||
@@ -31,7 +31,27 @@ import type {
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
} from "./types";
|
||||
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
|
||||
|
||||
export interface RealDebridLoginRequest {
|
||||
accountId: string;
|
||||
create?: boolean;
|
||||
}
|
||||
|
||||
export function validateRealDebridLoginRequest(value: unknown): Required<RealDebridLoginRequest> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (Object.keys(raw).some((key) => key !== "accountId" && key !== "create")
|
||||
|| typeof raw.accountId !== "string"
|
||||
|| !isRealDebridWebAccountId(raw.accountId)
|
||||
|| (raw.create !== undefined && typeof raw.create !== "boolean")) {
|
||||
throw new Error("Account-Payload ist ungültig");
|
||||
}
|
||||
return { accountId: raw.accountId.trim(), create: raw.create === true };
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
@@ -98,7 +118,7 @@ export interface ElectronApi {
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openRealDebridLogin: (request?: RealDebridLoginRequest) => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||
|
||||
@@ -74,6 +74,16 @@ describe("account preload contract", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards account-bound existing and create browser logins", async () => {
|
||||
await electron.api?.openRealDebridLogin({ accountId: "rdw_existing" });
|
||||
await electron.api?.openRealDebridLogin({ accountId: "rdw_reserved", create: true });
|
||||
|
||||
expect(electron.invoke.mock.calls).toEqual([
|
||||
[IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_existing" }],
|
||||
[IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_reserved", create: true }]
|
||||
]);
|
||||
});
|
||||
|
||||
it("reveals a stored secret only through the explicit account channel", async () => {
|
||||
electron.invoke.mockResolvedValueOnce({ secret: "fixture-revealed-secret-7gH8" });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertTrustedIpcSender } from "../src/main/ipc-security";
|
||||
import { validateRealDebridLoginRequest } from "../src/shared/preload-api";
|
||||
|
||||
function eventFor(url: string) {
|
||||
return {
|
||||
@@ -13,6 +14,14 @@ function eventFor(url: string) {
|
||||
}
|
||||
|
||||
describe("ipc-security", () => {
|
||||
it("accepts only opaque Real-Debrid browser account login requests", () => {
|
||||
expect(validateRealDebridLoginRequest({ accountId: "rdw_existing" })).toEqual({ accountId: "rdw_existing", create: false });
|
||||
expect(validateRealDebridLoginRequest({ accountId: "rdw_reserved", create: true })).toEqual({ accountId: "rdw_reserved", create: true });
|
||||
expect(() => validateRealDebridLoginRequest({ accountId: "../shared", create: true })).toThrow(/Account-Payload/i);
|
||||
expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: "yes" })).toThrow(/Account-Payload/i);
|
||||
expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: false, token: "secret" })).toThrow(/Account-Payload/i);
|
||||
});
|
||||
|
||||
it("accepts IPC from the configured Vite development renderer origin", () => {
|
||||
expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), {
|
||||
isPackaged: false,
|
||||
|
||||
@@ -33,10 +33,11 @@ const {
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show,
|
||||
focus,
|
||||
close: vi.fn(() => {
|
||||
destroyed = true;
|
||||
windowEvents.closed?.();
|
||||
focus,
|
||||
close: vi.fn(() => {
|
||||
windowEvents.close?.();
|
||||
destroyed = true;
|
||||
windowEvents.closed?.();
|
||||
}),
|
||||
setMenuBarVisibility: vi.fn(),
|
||||
loadURL,
|
||||
@@ -88,7 +89,9 @@ vi.mock("electron", () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
|
||||
describe("realdebrid-web", () => {
|
||||
const mockSession = {
|
||||
@@ -130,7 +133,7 @@ describe("realdebrid-web", () => {
|
||||
|
||||
mockExecuteJavaScript.mockResolvedValue("token-from-window");
|
||||
|
||||
const fallback = new RealDebridWebFallback(() => true);
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true);
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
||||
@@ -173,7 +176,7 @@ describe("realdebrid-web", () => {
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", apiFetch);
|
||||
|
||||
const fallback = new RealDebridWebFallback(() => true);
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true);
|
||||
await fallback.openLoginWindow();
|
||||
const status = await fallback.probeLoginState();
|
||||
|
||||
@@ -197,9 +200,244 @@ describe("realdebrid-web", () => {
|
||||
it("notifies the controller when a new browser token is detected", async () => {
|
||||
mockExecuteJavaScript.mockResolvedValue("new-browser-token");
|
||||
const onAuthenticated = vi.fn();
|
||||
const fallback = new RealDebridWebFallback(() => true, onAuthenticated);
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true, onAuthenticated);
|
||||
|
||||
await fallback.openLoginWindow();
|
||||
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("uses isolated persistent and transient partitions for separate browser accounts", async () => {
|
||||
const first = new RealDebridWebFallback("persist:realdebrid-web-rdw_first", () => true);
|
||||
const second = new RealDebridWebFallback("persist:realdebrid-web-rdw_second", () => true);
|
||||
|
||||
await first.openLoginWindow();
|
||||
await second.openLoginWindow();
|
||||
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||
expect(mockBrowserWindowCtor.mock.calls.map((call) => (call[0] as any).webPreferences.partition)).toEqual([
|
||||
"persist:realdebrid-web-rdw_first",
|
||||
"persist:realdebrid-web-rdw_second"
|
||||
]);
|
||||
|
||||
await first.clearSessions();
|
||||
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_first");
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_first");
|
||||
expect(mockFromPartition).not.toHaveBeenCalledWith("persist:realdebrid-web-rdw_second");
|
||||
});
|
||||
|
||||
it("rejects unsafe browser partitions", () => {
|
||||
expect(() => new RealDebridWebFallback("persist:realdebrid-web-../shared", () => true)).toThrow(/Partition/i);
|
||||
});
|
||||
|
||||
it("persists a reserved browser account only after a successful probe", async () => {
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
const applyStatuses = vi.fn();
|
||||
const updateSettings = vi.fn((settings) => {
|
||||
controller.settings = settings;
|
||||
});
|
||||
const fallback = {
|
||||
probeLoginState: vi.fn().mockResolvedValueOnce({
|
||||
valid: false,
|
||||
username: "",
|
||||
email: "",
|
||||
isPremium: false,
|
||||
premiumUntilMs: null,
|
||||
message: "Nicht angemeldet"
|
||||
}).mockResolvedValueOnce({
|
||||
valid: true,
|
||||
username: "fixture-user",
|
||||
email: "fixture@example.test",
|
||||
isPremium: true,
|
||||
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
|
||||
message: "Premium aktiv"
|
||||
}),
|
||||
dispose: vi.fn()
|
||||
};
|
||||
controller.settings = defaultSettings();
|
||||
controller.pendingRealDebridWebAccountIds = new Map([["rdw_reserved", 0]]);
|
||||
controller.realDebridWebGenerations = new Map([["rdw_reserved", 0]]);
|
||||
controller.realDebridWebFallbacks = new Map([["rdw_reserved", fallback]]);
|
||||
controller.manager = { applyDebridAccountStatuses: applyStatuses };
|
||||
controller.updateSettings = updateSettings;
|
||||
controller.getRealDebridWebFallback = () => fallback;
|
||||
|
||||
await controller.refreshRealDebridWebStatus("rdw_reserved");
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
expect(applyStatuses).not.toHaveBeenCalled();
|
||||
expect(controller.settings.realDebridWebAccountIds).toEqual([]);
|
||||
|
||||
await controller.refreshRealDebridWebStatus("rdw_reserved");
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_reserved"]);
|
||||
expect(applyStatuses).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ accountId: "rdw_reserved", valid: true, username: "fixture-user" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a successful probe that completes after its account was deleted", async () => {
|
||||
let resolveProbe!: (value: Record<string, unknown>) => void;
|
||||
const probe = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
const settings = defaultSettings();
|
||||
settings.realDebridWebAccountIds = ["rdw_existing"];
|
||||
const applyStatuses = vi.fn();
|
||||
controller.settings = settings;
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.realDebridWebGenerations = new Map([["rdw_existing", 0]]);
|
||||
controller.manager = { applyDebridAccountStatuses: applyStatuses };
|
||||
controller.getRealDebridWebFallback = () => ({ probeLoginState: () => probe, dispose: vi.fn() });
|
||||
|
||||
const running = controller.refreshRealDebridWebStatus("rdw_existing", 0);
|
||||
controller.settings = { ...settings, realDebridWebAccountIds: [] };
|
||||
controller.realDebridWebGenerations.set("rdw_existing", 1);
|
||||
resolveProbe({
|
||||
valid: true,
|
||||
username: "deleted-user",
|
||||
email: "deleted@example.test",
|
||||
isPremium: true,
|
||||
premiumUntilMs: Date.now() + 60_000,
|
||||
message: "Premium aktiv"
|
||||
});
|
||||
await running;
|
||||
|
||||
expect(applyStatuses).not.toHaveBeenCalled();
|
||||
expect(controller.settings.realDebridWebAccountIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears cold account partitions without retaining a fallback instance", async () => {
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.settings = defaultSettings();
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.realDebridWebGenerations = new Map();
|
||||
controller.realDebridWebAuthenticationTasks = new Map();
|
||||
|
||||
await controller.cleanupRealDebridWebAccount("rdw_cold", true);
|
||||
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_cold");
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_cold");
|
||||
expect(controller.realDebridWebFallbacks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans a reserved account completely when opening its login window fails", async () => {
|
||||
mockLoadURL.mockRejectedValueOnce(new Error("load failed"));
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.settings = defaultSettings();
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.realDebridWebGenerations = new Map();
|
||||
controller.realDebridWebAuthenticationTasks = new Map();
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await expect(controller.openRealDebridLoginWindow({ accountId: "rdw_failed", create: true })).rejects.toThrow("load failed");
|
||||
|
||||
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
|
||||
expect(controller.realDebridWebFallbacks.size).toBe(0);
|
||||
expect(mockBrowserWindow.close).toHaveBeenCalled();
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_failed");
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_failed");
|
||||
});
|
||||
|
||||
it("cleans a reserved account when the user closes its login window", async () => {
|
||||
mockExecuteJavaScript.mockResolvedValue("");
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.settings = defaultSettings();
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.realDebridWebGenerations = new Map();
|
||||
controller.realDebridWebAuthenticationTasks = new Map();
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true });
|
||||
mockBrowserWindow.close();
|
||||
await vi.waitFor(() => expect(controller.realDebridWebFallbacks.size).toBe(0));
|
||||
|
||||
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_closed");
|
||||
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_closed");
|
||||
});
|
||||
|
||||
it("does not prime or notify while a browser window is cleared programmatically", async () => {
|
||||
mockExecuteJavaScript.mockResolvedValue("");
|
||||
const onAuthenticated = vi.fn();
|
||||
const onClosed = vi.fn();
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_cleanup", () => true, onAuthenticated, onClosed);
|
||||
await fallback.openLoginWindow();
|
||||
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalled());
|
||||
mockExecuteJavaScript.mockReset();
|
||||
mockExecuteJavaScript.mockResolvedValue("stale-token");
|
||||
|
||||
await fallback.clearSessions();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockExecuteJavaScript).not.toHaveBeenCalled();
|
||||
expect(onAuthenticated).not.toHaveBeenCalled();
|
||||
expect(onClosed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets close-time authentication finish before cleaning a reserved account", async () => {
|
||||
let resolveClosingToken!: (token: string) => void;
|
||||
const closingToken = new Promise<string>((resolve) => {
|
||||
resolveClosingToken = resolve;
|
||||
});
|
||||
mockExecuteJavaScript.mockResolvedValueOnce("").mockReturnValueOnce(closingToken);
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
username: "close-user",
|
||||
email: "close-user@example.test",
|
||||
type: "premium",
|
||||
expiration: "2030-01-02T03:04:05.000Z"
|
||||
}), { status: 200 })));
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.settings = defaultSettings();
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.realDebridWebGenerations = new Map();
|
||||
controller.realDebridWebAuthenticationTasks = new Map();
|
||||
controller.manager = { applyDebridAccountStatuses: vi.fn() };
|
||||
controller.updateSettings = vi.fn((settings) => {
|
||||
controller.settings = settings;
|
||||
});
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true });
|
||||
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
|
||||
mockBrowserWindow.close();
|
||||
resolveClosingToken("close-time-token");
|
||||
await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"]));
|
||||
|
||||
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
|
||||
expect(mockFromPartition).not.toHaveBeenCalledWith("persist:realdebrid-web-rdw_close_auth");
|
||||
expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ accountId: "rdw_close_auth", valid: true, username: "close-user" })
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["clear", "dispose"] as const)("ignores a session token that resolves after %s", async (mode) => {
|
||||
let resolveSessionFetch!: (response: Response) => void;
|
||||
const delayedResponse = new Promise<Response>((resolve) => {
|
||||
resolveSessionFetch = resolve;
|
||||
});
|
||||
mockSessionFetch.mockReturnValueOnce(delayedResponse);
|
||||
const onAuthenticated = vi.fn();
|
||||
const fallback = new RealDebridWebFallback(`persist:realdebrid-web-rdw_delayed_${mode}`, () => true, onAuthenticated);
|
||||
|
||||
const probing = fallback.probeLoginState();
|
||||
await vi.waitFor(() => expect(mockSessionFetch).toHaveBeenCalledTimes(1));
|
||||
if (mode === "clear") {
|
||||
await fallback.clearSessions();
|
||||
} else {
|
||||
fallback.dispose();
|
||||
}
|
||||
resolveSessionFetch(new Response("<input name=\"private_token\" value=\"late-token\">", { status: 200 }));
|
||||
await probing;
|
||||
await Promise.resolve();
|
||||
|
||||
expect((fallback as any).cachedToken).toBe("");
|
||||
expect(onAuthenticated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user