diff --git a/main.js b/main.js index e28f245..943d14c 100644 --- a/main.js +++ b/main.js @@ -1115,6 +1115,52 @@ ipcMain.handle('run-health-check', async (_event, payload) => { return runHosterHealthCheck(config, hosters); }); +// Validate ephemeral credentials WITHOUT persisting them to config.hosters. +// This is the IPC that backs the two-step "Prüfen → Anlegen" modal flow: the +// new account is never on disk until the user confirms after a green check, so +// failed/OTP-pending creds can't leak into config (and a double-click on the +// Prüfen button cannot create duplicates because nothing is written until the +// second, distinct "Anlegen" click). NOTE: this payload carries plaintext creds +// across the IPC boundary — same trust level as save-config — DO NOT log it. +ipcMain.handle('validate-credentials', async (_event, payload) => { + if (!payload || !payload.hoster) { + return { status: 'error', message: 'Hoster fehlt' }; + } + const ephemeralHosterConfig = { + username: payload.username || '', + password: payload.password || '', + apiKey: payload.apiKey || '', + enabled: true + }; + try { + return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || ''); + } catch (err) { + return { status: 'error', message: err && err.message ? err.message : 'Validierung fehlgeschlagen' }; + } +}); + +async function _dispatchHealthCheck(hoster, hosterConfig, otp) { + // Mirrors the per-hoster switch in runHosterHealthCheck so both code paths + // (batch check by accountId and ephemeral validate) go through identical + // checkers + timeout wrappers and surface identical result shapes. + if (hoster === 'doodstream.com') { + return withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check'); + } + if (hoster === 'vidmoly.me') { + return withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check'); + } + if (hoster === 'voe.sx') { + return withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check'); + } + if (hoster === 'byse.sx') { + return withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check'); + } + if (hoster === 'clouddrop.cc') { + return withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check'); + } + return { status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; +} + ipcMain.handle('select-files', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile', 'multiSelections'], diff --git a/preload.js b/preload.js index 654793b..ed16a3b 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', { addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload), finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), + validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload), // Log import readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'), diff --git a/renderer/app.js b/renderer/app.js index 8441b31..cd32912 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3040,6 +3040,11 @@ function _buildAccountCardHtml(name, account, idx) { const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft'); const statusClass = isDisabled ? 'disabled' : st.status; const credLabel = getCredentialLabel(name, account); + const userLabel = account.label && String(account.label).trim(); + // Subtitle: "Label: XYZ • API: ABC… • " — the user-set label is the + // disambiguator for accounts that otherwise look identical (e.g. two byse + // API-key accounts where you can't tell what's what from the masked key). + const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; @@ -3048,7 +3053,7 @@ function _buildAccountCardHtml(name, account, idx) {
- +
@@ -3268,6 +3273,9 @@ function getCredsFieldsHtml(authType, account, hoster) { function openAccountModal(editAccountId) { editingAccountId = editAccountId || null; + // Reset the two-step state — any previously validated snapshot from a prior + // modal session is stale and must not allow a no-recheck commit. + _resetAccountModalState(); const modal = document.getElementById('accountModal'); const title = document.getElementById('accountModalTitle'); const subtitle = document.getElementById('accountModalSubtitle'); @@ -3276,6 +3284,7 @@ function openAccountModal(editAccountId) { const credsContainer = document.getElementById('accountCredsFields'); const statusEl = document.getElementById('accountModalStatus'); const saveBtn = document.getElementById('saveAccountBtn'); + const labelInput = document.getElementById('accField_label'); statusEl.textContent = ''; statusEl.className = 'account-modal-status'; @@ -3287,18 +3296,20 @@ function openAccountModal(editAccountId) { title.textContent = 'Account bearbeiten'; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; hosterRow.style.display = 'none'; - saveBtn.textContent = 'Speichern & prüfen'; + saveBtn.textContent = 'Prüfen'; + if (labelInput) labelInput.value = found.account.label || ''; credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); } else { // Add mode — always show all options (multiple accounts per hoster allowed) title.textContent = 'Account hinzufügen'; - subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; + subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".'; hosterRow.style.display = 'flex'; - saveBtn.textContent = 'Anlegen & prüfen'; + saveBtn.textContent = 'Prüfen'; hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => `` ).join(''); const firstOpt = HOSTER_ADD_OPTIONS[0]; + if (labelInput) labelInput.value = ''; credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); } @@ -3310,6 +3321,12 @@ function openAccountModal(editAccountId) { }); }); + // Wire field invalidation: any change to a cred field after a green check + // drops the validated snapshot so the next click is a re-check, not a commit + // of unverified creds. Re-wired here every open because credsContainer's HTML + // was replaced. + _wireCredFieldInvalidation(); + modal.style.display = 'flex'; } @@ -3317,6 +3334,11 @@ function closeAccountModal() { document.getElementById('accountModal').style.display = 'none'; _hideOtpField(); editingAccountId = null; + // Cancel any pending auto-close so a stale timer can't close a future modal + // the user reopens within the auto-close window. + if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } + _validatedCreds = null; + _accountModalBusy = false; } function openDeleteAccountModal(accountId) { @@ -3342,130 +3364,249 @@ async function deleteAccount(accountId) { config.hosters[found.name] = accounts.filter(a => a.id !== accountId); } delete accountStatuses[accountId]; - await window.api.saveConfig({ hosters: config.hosters }); - config = await window.api.getConfig(); + // saveConfig is async — close the modal immediately so the UI feels + // responsive instead of waiting for the atomic write + safeStorage encrypt. + // The in-memory config already reflects the delete; the IPC just persists it. + closeDeleteModal(); ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); renderAccounts(); renderHosterSummary(); - renderHosterModal(); - renderSettings(); - closeDeleteModal(); + // Fire-and-forget the persist. The earlier `await getConfig()` round-trip + // was redundant (we already have the truth in memory) and was the main + // source of perceived lag on add/delete. + window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); } function readAccountCredsFromModal(authType) { + const label = (document.getElementById('accField_label')?.value || '').trim(); if (authType === 'login') { const username = (document.getElementById('accField_username')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim(); - return { enabled: !!(username && password), authType: 'login', username, password }; + return { enabled: !!(username && password), authType: 'login', username, password, label }; } // API const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); - return { enabled: !!apiKey, authType: 'api', apiKey }; + return { enabled: !!apiKey, authType: 'api', apiKey, label }; +} + +// --- Two-step account-modal state machine --- +// +// Goal: never persist invalid/unverified credentials to config.hosters. The +// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green +// the button label flips to "Anlegen" / "Speichern" → the next click commits +// to config. Editing any cred field between the two clicks drops the validated +// snapshot so the user can't sneak unverified creds through by editing +// post-green. +// +// Invariants enforced here: +// 1. Nothing reaches config.hosters until _validatedCreds matches a green +// result for the currently-typed creds. +// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler +// before any await — guards against double-clicks producing duplicates. +// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with +// the new OTP, no config writes until green. +// 4. Edit mode hits the same path → bad edits never overwrite known-good +// creds on disk. +let _accountModalBusy = false; +let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green +let _autoCloseTimer = null; +// Session token used to ignore stale validate-credentials responses: if the +// user closes the modal mid-flight and reopens it, the late .then must NOT +// stomp the new session's state. Bumped on every modal reset. +let _accountModalSession = 0; + +function _resetAccountModalState() { + _accountModalBusy = false; + _validatedCreds = null; + _accountModalSession++; + if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } +} + +function _credsSnapshotKey(authType, creds) { + // Identity key for the typed creds — used to detect post-validation edits. + // Label changes do NOT invalidate (label is metadata, not a credential). + if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; + return `api:${creds.apiKey || ''}`; +} + +function _wireCredFieldInvalidation() { + // Any change to a cred IDENTITY field (username/password/apiKey) clears the + // validated snapshot and reverts the button to "Prüfen". Label edits don't + // invalidate (label is metadata, not a credential). OTP edits don't either: + // OTP is an ephemeral auth challenge — once doodstream returned "ok" for + // these username+password+OTP, the resulting trust is on the creds; the user + // clearing or fixing the OTP field afterward shouldn't force a re-prompt. + const ids = ['accField_username', 'accField_password', 'accField_apiKey']; + for (const id of ids) { + const el = document.getElementById(id); + if (!el || el.dataset.invalidateBound === '1') continue; + el.addEventListener('input', () => { + if (_validatedCreds) { + _validatedCreds = null; + const saveBtn = document.getElementById('saveAccountBtn'); + if (saveBtn) saveBtn.textContent = 'Prüfen'; + const statusEl = document.getElementById('accountModalStatus'); + if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; } + } + }); + el.dataset.invalidateBound = '1'; + } +} + +function _determineHosterContext() { + if (editingAccountId) { + const found = findAccountById(editingAccountId); + if (!found) return null; + return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true }; + } + const selectValue = document.getElementById('accountHosterSelect')?.value; + if (!selectValue) return null; + const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); + if (!opt) return null; + return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; } async function saveAccount() { - let hosterName, authType, accountId; + // SYNCHRONOUS re-entry guard — must come before any await. Without this a + // double-click before the first IPC returns triggers two saveAccount() calls + // and (in the old code) two pushes/two IPCs. _accountModalBusy is checked + // synchronously and set synchronously, so the second click no-ops cleanly. + if (_accountModalBusy) return; - if (editingAccountId) { - // Edit existing account - const found = findAccountById(editingAccountId); - if (!found) return; - hosterName = found.name; - authType = found.account.authType || 'login'; - accountId = editingAccountId; - } else { - // Add new account - const selectValue = document.getElementById('accountHosterSelect')?.value; - if (!selectValue) return; - const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); - if (!opt) return; - hosterName = opt.hoster; - authType = opt.authType; - accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - } - - const creds = readAccountCredsFromModal(authType); + const ctx = _determineHosterContext(); + if (!ctx) return; + const creds = readAccountCredsFromModal(ctx.authType); + const statusEl = document.getElementById('accountModalStatus'); + const saveBtn = document.getElementById('saveAccountBtn'); if (!creds.enabled) { - const statusEl = document.getElementById('accountModalStatus'); statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.className = 'account-modal-status error'; return; } - // Save credentials - if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = []; - if (editingAccountId) { - // Update existing account in array - const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId); - if (idx >= 0) { - config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds }; + // STEP 2: commit. Only fires if a previous "Prüfen" already validated the + // EXACT same creds (label changes don't break this — label isn't part of the + // credential identity). + const snapshotKey = _credsSnapshotKey(ctx.authType, creds); + if (_validatedCreds && + _validatedCreds.hosterName === ctx.hosterName && + _validatedCreds.authType === ctx.authType && + _validatedCreds.snapshot === snapshotKey) { + // Set busy INSIDE the try so a sync throw on the saveBtn deref above can't + // leak _accountModalBusy=true and lock the user out for the session. + try { + _accountModalBusy = true; + saveBtn.disabled = true; + saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…'; + await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message); + } finally { + _accountModalBusy = false; + if (saveBtn) saveBtn.disabled = false; } - } else { - // Add new account - config.hosters[hosterName].push({ id: accountId, ...creds }); + return; } - await window.api.saveConfig({ hosters: config.hosters }); - config = await window.api.getConfig(); - // Show checking status - const statusEl = document.getElementById('accountModalStatus'); - const saveBtn = document.getElementById('saveAccountBtn'); - statusEl.textContent = 'Prüfe Login...'; - statusEl.className = 'account-modal-status checking'; + // STEP 1: validate ephemerally. NOTHING is written to config.hosters here. + // Snapshot the session token so a stale late-arriving response from a + // closed-and-reopened modal can't stomp the new session's state. + const mySession = _accountModalSession; + _accountModalBusy = true; saveBtn.disabled = true; + statusEl.textContent = 'Prüfe Login…'; + statusEl.className = 'account-modal-status checking'; - accountStatuses[accountId] = { status: 'checking', message: '' }; - syncSelectedUploadHosters(); - renderAccounts(); - renderHosterSummary(); - renderHosterModal(); - renderSettings(); - - // Check if OTP was entered (for retry after OTP prompt) const otpInput = document.getElementById('accField_otp'); const otp = otpInput ? otpInput.value.trim() : ''; + const payload = { + hoster: ctx.hosterName, + authType: ctx.authType, + username: creds.username || '', + password: creds.password || '', + apiKey: creds.apiKey || '', + otp + }; - // Run health check for this specific account (include OTP if provided) - const checkPayload = { hoster: hosterName, accountId }; - if (otp) checkPayload.otp = otp; - + let row; try { - const result = await window.api.runHealthCheck({ hosters: [checkPayload] }); - const rows = result && Array.isArray(result.results) ? result.results : []; - const row = rows.find(r => r.accountId === accountId); - if (row && row.status === 'otp_required') { - // Show OTP input field if not already visible - accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' }; - statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; - statusEl.className = 'account-modal-status error'; - _showOtpField(); - saveBtn.textContent = 'OTP bestätigen'; - } else if (row && (row.status === 'ok' || row.status === 'warn')) { - accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' }; - statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; - statusEl.className = 'account-modal-status ok'; - _hideOtpField(); - setTimeout(() => closeAccountModal(), 1200); - } else { - const msg = (row && row.message) || 'Login fehlgeschlagen'; - accountStatuses[accountId] = { status: 'error', message: msg }; - statusEl.textContent = msg; - statusEl.className = 'account-modal-status error'; - } + row = await window.api.validateCredentials(payload); } catch (err) { - accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; - statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; - statusEl.className = 'account-modal-status error'; + row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' }; } finally { - saveBtn.disabled = false; - ensureAccountStatusEntries(); - renderAccounts(); - renderHosterSummary(); - renderHosterModal(); - renderSettings(); + if (mySession === _accountModalSession) { + _accountModalBusy = false; + if (saveBtn) saveBtn.disabled = false; + } } + + // Stale response — modal was closed/reopened while we awaited. Drop it. + if (mySession !== _accountModalSession) return; + + if (row && row.status === 'otp_required') { + statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; + statusEl.className = 'account-modal-status error'; + _showOtpField(); + _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too + saveBtn.textContent = 'Mit OTP prüfen'; + return; + } + if (row && (row.status === 'ok' || row.status === 'warn')) { + statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.'; + statusEl.className = 'account-modal-status ok'; + _hideOtpField(); + _validatedCreds = { + hosterName: ctx.hosterName, + authType: ctx.authType, + snapshot: snapshotKey, + status: row.status, + message: row.message || '' + }; + saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen'; + return; + } + // error + const msg = (row && row.message) || 'Login fehlgeschlagen'; + statusEl.textContent = msg; + statusEl.className = 'account-modal-status error'; +} + +async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) { + // Persist the validated creds to config.hosters and close the modal. By the + // time we reach this function the validate-credentials IPC has already + // returned ok/warn for these exact creds, so we skip a redundant re-check. + let accountId; + if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = []; + if (ctx.isEdit) { + accountId = ctx.accountId; + const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId); + if (idx >= 0) { + config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds }; + } + } else { + accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + config.hosters[ctx.hosterName].push({ id: accountId, ...creds }); + } + await window.api.saveConfig({ hosters: config.hosters }); + // Skip the redundant await getConfig() — the in-memory state is the source + // of truth for what we just wrote, decrypted creds didn't change, and the + // round-trip was the main lag source on add/delete. + accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' }; + ensureAccountStatusEntries(); + syncSelectedUploadHosters(); + // Targeted updates instead of the 4-panel cascade. For add we need a full + // accounts-list re-render (new card) and the hoster summary count; for edit + // we can update the single card. Settings panel only needs re-render if its + // hoster-summary section is visible — that's covered by renderHosterSummary. + if (ctx.isEdit) { + updateAccountCard(accountId); + } else { + renderAccounts(); + } + renderHosterSummary(); + // Auto-close after a short pause so the user sees the success state. + if (_autoCloseTimer) clearTimeout(_autoCloseTimer); + _autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600); } function _showOtpField() { @@ -3924,6 +4065,14 @@ function setupListeners() { }); document.getElementById('accountModalStatus').textContent = ''; document.getElementById('accountModalStatus').className = 'account-modal-status'; + // Hoster changed → any prior validation is stale by construction. Drop the + // snapshot and revert the button so the user has to re-Prüfen. + _validatedCreds = null; + const sb = document.getElementById('saveAccountBtn'); + if (sb) sb.textContent = 'Prüfen'; + // The cred inputs were just replaced — rewire invalidation listeners on + // the fresh elements so post-validation edits still revert the button. + _wireCredFieldInvalidation(); }); // Delete account modal diff --git a/renderer/index.html b/renderer/index.html index 78769af..b894f80 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -187,6 +187,10 @@ +
+ + +
diff --git a/tests/validate-credentials.test.js b/tests/validate-credentials.test.js new file mode 100644 index 0000000..58a3c7d --- /dev/null +++ b/tests/validate-credentials.test.js @@ -0,0 +1,195 @@ +// Pure unit tests for the validate-credentials shape contract — does NOT spin +// up Electron or the real per-hoster checkers. Those need network. We verify +// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster +// checkers consume) plus the snapshot-key/invalidation invariants that the +// renderer relies on to enforce "validated creds only". +// +// The three assertions the advisor called out as the regression guard for the +// user's "mehrfach angelegt" complaint: +// (a) failed validation persists nothing to config.hosters +// (b) a second "Anlegen" click with the guard set persists exactly one entry +// (c) OTP-required path persists nothing +// are exercised at the state-machine level by simulating the renderer's logic +// (re-implemented here as pure functions for testability — the real ones live +// in renderer/app.js which can't run under node:test). + +const { test } = require('node:test'); +const assert = require('node:assert'); + +// ---- Re-implementations of the renderer's pure helpers ---- +// These mirror the production code exactly so the tests serve as both a guard +// and executable spec for what saveAccount() must do. + +function credsSnapshotKey(authType, creds) { + if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; + return `api:${creds.apiKey || ''}`; +} + +function buildEphemeralHosterConfig(payload) { + return { + username: payload.username || '', + password: payload.password || '', + apiKey: payload.apiKey || '', + enabled: true + }; +} + +// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC. +function makeStateMachine({ validateImpl, persistImpl }) { + let busy = false; + let validated = null; // { hosterName, authType, snapshot, status } + const log = []; // log of every persist call, for assertions + + async function click(ctx, creds, otp = '') { + if (busy) { log.push({ type: 'click-ignored-busy' }); return; } + const snapshot = credsSnapshotKey(ctx.authType, creds); + + // STEP 2: commit if validated matches. + if (validated && + validated.hosterName === ctx.hosterName && + validated.authType === ctx.authType && + validated.snapshot === snapshot) { + busy = true; + try { + await persistImpl(ctx, creds); + log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` }); + } finally { busy = false; } + return; + } + + // STEP 1: ephemeral validate. + busy = true; + let row; + try { + row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp }); + } finally { busy = false; } + if (row && (row.status === 'ok' || row.status === 'warn')) { + validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status }; + log.push({ type: 'validated', status: row.status }); + return; + } + if (row && row.status === 'otp_required') { + log.push({ type: 'otp-required' }); + return; + } + log.push({ type: 'validation-failed', message: row && row.message }); + } + + function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); } + return { click, editField, log: () => log.slice(), getValidated: () => validated }; +} + +// ---- Tests ---- + +test('regression (a): failed validation persists NOTHING to config.hosters', async () => { + const persistCalls = []; + const sm = makeStateMachine({ + validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }), + persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) + }); + await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' }); + assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation'); + assert.equal(sm.getValidated(), null); + assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']); +}); + +test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => { + const persistCalls = []; + let validateCount = 0; + const sm = makeStateMachine({ + validateImpl: async () => { validateCount++; return { status: 'ok' }; }, + persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) + }); + const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; + const creds = { username: 'u', password: 'p' }; + // Click 1 = validate → green. + await sm.click(ctx, creds); + // Click 2 = commit (same creds, validated snapshot matches). + await sm.click(ctx, creds); + // Click 3 = guard prevents a second commit because after persistImpl the + // state-machine in real code closes the modal. In this simulator the + // validated snapshot is still set — but a real double-click WHILE persistImpl + // is in flight would be caught by busy. Simulate that: + const sm2 = makeStateMachine({ + validateImpl: async () => ({ status: 'ok' }), + persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30)) + }); + await sm2.click(ctx, creds); // validate + const p1 = sm2.click(ctx, creds); // start commit + const p2 = sm2.click(ctx, creds); // racing click — must be ignored + await Promise.all([p1, p2]); + + assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored'); + assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate'); + // The racing click MUST have been ignored by the busy guard. + assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click'); +}); + +test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => { + const persistCalls = []; + let calls = 0; + const sm = makeStateMachine({ + validateImpl: async (payload) => { + calls++; + if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' }; + if (payload.otp === '123456') return { status: 'ok' }; + return { status: 'error', message: 'Bad OTP' }; + }, + persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) + }); + const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; + const creds = { username: 'u', password: 'p' }; + await sm.click(ctx, creds, ''); // first click → otp_required + await sm.click(ctx, creds, '123456'); // retry with otp → ok + await sm.click(ctx, creds); // final click → commit + assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed'); + assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit'); + assert.deepEqual( + sm.log().map(e => e.type), + ['otp-required', 'validated', 'persisted'] + ); +}); + +test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => { + const persistCalls = []; + let validateCount = 0; + const sm = makeStateMachine({ + validateImpl: async () => { validateCount++; return { status: 'ok' }; }, + persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) + }); + const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; + await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green + sm.editField(); // user edits cred field → snapshot dropped + await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate + await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds + assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds'); + assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set'); + assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation'); +}); + +test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => { + // Label changes must NOT invalidate validation — label is metadata, not a credential. + assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }), + credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' })); + assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }), + credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case + assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }), + credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff + assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }), + credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' })); + assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }), + credsSnapshotKey('api', { apiKey: 'KEY2' })); +}); + +test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => { + // The per-hoster checkers in main.js read .username/.password/.apiKey directly. + // This guards the validate-credentials IPC contract from drifting. + const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' }); + assert.equal(cfg.username, 'u'); + assert.equal(cfg.password, 'p'); + assert.equal(cfg.apiKey, ''); + assert.equal(cfg.enabled, true); + const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' }); + assert.equal(cfg2.apiKey, 'K'); + assert.equal(cfg2.username, ''); +});