diff --git a/renderer/account-submit.js b/renderer/account-submit.js new file mode 100644 index 0000000..cb5fb1a --- /dev/null +++ b/renderer/account-submit.js @@ -0,0 +1,29 @@ +(function (scope) { + async function submitValidatedAccount({ validate, commit, isCurrent }) { + let validation; + try { + validation = await validate(); + } catch (error) { + return { status: 'error', error }; + } + + if (!isCurrent()) return { status: 'stale', validation }; + if (validation && validation.status === 'otp_required') { + return { status: 'otp_required', validation }; + } + if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) { + return { status: 'rejected', validation }; + } + + try { + await commit(validation); + return { status: 'committed', validation }; + } catch (error) { + return { status: 'error', error, validation }; + } + } + + const accountSubmit = { submitValidatedAccount }; + if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit; + if (scope) scope.AccountSubmit = accountSubmit; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/renderer/app.js b/renderer/app.js index 62b9b59..5ca1760 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -4260,8 +4260,6 @@ 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'); @@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) { const found = findAccountById(editingAccountId); if (!found) return; title.textContent = 'Account bearbeiten'; - subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; + subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`; hosterRow.style.display = 'none'; - saveBtn.textContent = 'Prüfen'; + saveBtn.textContent = 'Prüfen & speichern'; 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. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".'; + subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.'; hosterRow.style.display = 'flex'; - saveBtn.textContent = 'Prüfen'; + saveBtn.textContent = 'Anlegen & prüfen'; hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => `` ).join(''); @@ -4308,10 +4306,6 @@ 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'; @@ -4321,11 +4315,7 @@ 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; + _resetAccountModalState(); } function openDeleteAccountModal(accountId) { @@ -4381,66 +4371,61 @@ function readAccountCredsFromModal(authType) { 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 _accountModalBusySession = null; 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++; + _accountModalBusy = false; + _accountModalBusySession = null; if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } + const saveBtn = document.getElementById('saveAccountBtn'); + if (saveBtn) saveBtn.disabled = false; } 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 _defaultAccountSubmitButtonText(ctx, hasOtp) { + if (hasOtp) return ctx && ctx.isEdit ? 'Mit OTP prüfen & speichern' : 'Mit OTP anlegen & prüfen'; + return ctx && ctx.isEdit ? 'Prüfen & speichern' : 'Anlegen & prüfen'; +} + +function _invalidateAccountSubmit() { + _accountModalSession++; + const statusEl = document.getElementById('accountModalStatus'); + if (statusEl) { + statusEl.textContent = ''; + statusEl.className = 'account-modal-status'; + } + const saveBtn = document.getElementById('saveAccountBtn'); + if (saveBtn && !_accountModalBusy) { + saveBtn.disabled = false; + saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext(), !!document.getElementById('accField_otp')); + } +} + +function _releaseAccountSubmit(session) { + if (_accountModalBusySession !== session) return; + _accountModalBusy = false; + _accountModalBusySession = null; + const saveBtn = document.getElementById('saveAccountBtn'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext(), !!document.getElementById('accField_otp')); + } +} + 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.addEventListener('input', _invalidateAccountSubmit); el.dataset.invalidateBound = '1'; } } @@ -4458,11 +4443,17 @@ function _determineHosterContext() { return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; } +function _isAccountSubmitCurrent(session, ctx, snapshotKey) { + if (session !== _accountModalSession) return false; + const currentCtx = _determineHosterContext(); + if (!currentCtx) return false; + if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false; + if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false; + const currentCreds = readAccountCredsFromModal(currentCtx.authType); + return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey; +} + async function saveAccount() { - // 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; const ctx = _determineHosterContext(); @@ -4476,35 +4467,13 @@ async function saveAccount() { return; } - // 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; - } - return; - } - - // 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; + _accountModalBusySession = mySession; saveBtn.disabled = true; - statusEl.textContent = 'Prüfe Login…'; + saveBtn.textContent = ctx.isEdit ? 'Prüfe und speichere…' : 'Prüfe und lege an…'; + statusEl.textContent = 'Prüfe Zugangsdaten…'; statusEl.className = 'account-modal-status checking'; const otpInput = document.getElementById('accField_otp'); @@ -4518,94 +4487,86 @@ async function saveAccount() { otp }; - let row; + let result; try { - row = await window.api.validateCredentials(payload); - } catch (err) { - row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' }; - } finally { - if (mySession === _accountModalSession) { - _accountModalBusy = false; - if (saveBtn) saveBtn.disabled = false; - } + result = await window.AccountSubmit.submitValidatedAccount({ + validate: () => window.api.validateCredentials(payload), + commit: (validation) => _commitAccount(ctx, creds, validation), + isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey) + }); + } catch (error) { + result = { status: 'error', error }; } - // 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.'; + const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey); + if (result.status === 'committed' && current) { + const validation = result.validation || {}; + statusEl.textContent = validation.status === 'warn' + ? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.' + : validation.message || 'Account wurde erfolgreich geprüft und gespeichert.'; 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'; + saveBtn.textContent = ctx.isEdit ? 'Gespeichert' : 'Angelegt'; + saveBtn.disabled = true; + if (_autoCloseTimer) clearTimeout(_autoCloseTimer); + _autoCloseTimer = setTimeout(() => { + _autoCloseTimer = null; + closeAccountModal(); + }, 600); return; } - // error - const msg = (row && row.message) || 'Login fehlgeschlagen'; + + _releaseAccountSubmit(mySession); + if (!current) return; + + if (result.status === 'otp_required') { + const validation = result.validation || {}; + statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.'; + statusEl.className = 'account-modal-status error'; + _showOtpField(); + saveBtn.textContent = _defaultAccountSubmitButtonText(ctx, true); + return; + } + + const validation = result.validation || {}; + const msg = result.status === 'error' + ? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen' + : validation.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] = []; +function _copyHosterTree(hosters) { + const candidate = {}; + for (const [name, accounts] of Object.entries(hosters || {})) { + candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts; + } + return candidate; +} + +async function _commitAccount(ctx, creds, validation) { + const candidateHosters = _copyHosterTree(config.hosters); + if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = []; + let accountId = ctx.accountId; 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 { - _accountModalBusy = false; - const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false; - const _st = document.getElementById('accountModalStatus'); - if (_st) { - _st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.'; - _st.className = 'account-modal-status error'; - } - return; - } + const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId); + if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.'); + candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[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 }); + candidateHosters[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 || '' }; + await window.api.saveConfig({ hosters: candidateHosters }); + config.hosters = candidateHosters; + accountStatuses[accountId] = { status: validation.status, message: validation.message || '' }; 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() { @@ -5170,6 +5131,7 @@ function setupListeners() { // Account hoster select change → update credential fields document.getElementById('accountHosterSelect').addEventListener('change', (e) => { + _invalidateAccountSubmit(); const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const authType = opt ? opt.authType : 'login'; const credsContainer = document.getElementById('accountCredsFields'); @@ -5180,15 +5142,6 @@ function setupListeners() { input.type = input.type === 'password' ? 'text' : 'password'; }); }); - 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(); }); diff --git a/renderer/index.html b/renderer/index.html index 15b1a93..84a07a9 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -421,6 +421,7 @@ + diff --git a/tests/validate-credentials.test.js b/tests/validate-credentials.test.js index 58a3c7d..b54e245 100644 --- a/tests/validate-credentials.test.js +++ b/tests/validate-credentials.test.js @@ -1,195 +1,128 @@ -// 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'); +const assert = require('node:assert/strict'); +const { submitValidatedAccount } = require('../renderer/account-submit'); -// ---- 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' }; +test('ok validates and commits exactly once in one submission', async () => { + let validations = 0; + let commits = 0; + const result = await submitValidatedAccount({ + validate: async () => { + validations++; + return { status: 'ok', message: 'Login erfolgreich' }; }, - persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds }) + commit: async () => { + commits++; + }, + isCurrent: () => true }); - 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'] - ); + + assert.equal(result.status, 'committed'); + assert.equal(validations, 1); + assert.equal(commits, 1); }); -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 }) +test('warn validates and commits exactly once in one submission', async () => { + let commits = 0; + const validation = { status: 'warn', message: 'Login mit Warnung' }; + const result = await submitValidatedAccount({ + validate: async () => validation, + commit: async (received) => { + commits++; + assert.equal(received, validation); + }, + isCurrent: () => true }); - 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'); + + assert.equal(result.status, 'committed'); + assert.equal(result.validation, validation); + assert.equal(commits, 1); }); -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' })); +for (const status of ['error', 'skipped']) { + test(`${status} rejects without committing`, async () => { + let commits = 0; + const validation = { status, message: `${status} result` }; + const result = await submitValidatedAccount({ + validate: async () => validation, + commit: async () => { + commits++; + }, + isCurrent: () => true + }); + + assert.equal(result.status, 'rejected'); + assert.equal(result.validation, validation); + assert.equal(commits, 0); + }); +} + +test('validate throw returns error without committing', async () => { + const expected = new Error('validation failed'); + let commits = 0; + const result = await submitValidatedAccount({ + validate: async () => { + throw expected; + }, + commit: async () => { + commits++; + }, + isCurrent: () => true + }); + + assert.equal(result.status, 'error'); + assert.equal(result.error, expected); + assert.equal(commits, 0); }); -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, ''); +test('otp_required returns challenge without committing', async () => { + let commits = 0; + const validation = { status: 'otp_required', message: 'OTP gesendet' }; + const result = await submitValidatedAccount({ + validate: async () => validation, + commit: async () => { + commits++; + }, + isCurrent: () => true + }); + + assert.equal(result.status, 'otp_required'); + assert.equal(result.validation, validation); + assert.equal(commits, 0); +}); + +test('stale submission is rejected immediately before commit', async () => { + let current = true; + let commits = 0; + const validation = { status: 'ok' }; + const result = await submitValidatedAccount({ + validate: async () => { + current = false; + return validation; + }, + commit: async () => { + commits++; + }, + isCurrent: () => current + }); + + assert.equal(result.status, 'stale'); + assert.equal(result.validation, validation); + assert.equal(commits, 0); +}); + +test('save failure returns error after one commit attempt', async () => { + const expected = new Error('save failed'); + let commits = 0; + const result = await submitValidatedAccount({ + validate: async () => ({ status: 'ok' }), + commit: async () => { + commits++; + throw expected; + }, + isCurrent: () => true + }); + + assert.equal(result.status, 'error'); + assert.equal(result.error, expected); + assert.equal(commits, 1); });