feat(accounts): validate and save in one action

Keep account validation and persistence inside one guarded submission. Recheck modal identity before committing, persist a copied hoster candidate, and publish renderer state only after saveConfig succeeds.
This commit is contained in:
Administrator 2026-08-07 17:38:43 +02:00
parent b075961802
commit 0652edf69f
4 changed files with 258 additions and 342 deletions

View File

@ -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);

View File

@ -4260,8 +4260,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null; 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(); _resetAccountModalState();
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle'); const title = document.getElementById('accountModalTitle');
@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) {
const found = findAccountById(editingAccountId); const found = findAccountById(editingAccountId);
if (!found) return; if (!found) return;
title.textContent = 'Account bearbeiten'; 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'; hosterRow.style.display = 'none';
saveBtn.textContent = 'Prüfen'; saveBtn.textContent = 'Prüfen & speichern';
if (labelInput) labelInput.value = found.account.label || ''; if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else { } else {
// Add mode — always show all options (multiple accounts per hoster allowed) // Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen'; 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'; hosterRow.style.display = 'flex';
saveBtn.textContent = 'Prüfen'; saveBtn.textContent = 'Anlegen & prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>` `<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join(''); ).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(); _wireCredFieldInvalidation();
modal.style.display = 'flex'; modal.style.display = 'flex';
@ -4321,11 +4315,7 @@ function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
// Cancel any pending auto-close so a stale timer can't close a future modal _resetAccountModalState();
// the user reopens within the auto-close window.
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_validatedCreds = null;
_accountModalBusy = false;
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -4381,66 +4371,61 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label }; 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 _accountModalBusy = false;
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green let _accountModalBusySession = null;
let _autoCloseTimer = 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; let _accountModalSession = 0;
function _resetAccountModalState() { function _resetAccountModalState() {
_accountModalBusy = false;
_validatedCreds = null;
_accountModalSession++; _accountModalSession++;
_accountModalBusy = false;
_accountModalBusySession = null;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn) saveBtn.disabled = false;
} }
function _credsSnapshotKey(authType, creds) { 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 || ''}`; if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`; 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() { 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']; const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) { for (const id of ids) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue; if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', () => { el.addEventListener('input', _invalidateAccountSubmit);
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'; el.dataset.invalidateBound = '1';
} }
} }
@ -4458,11 +4443,17 @@ function _determineHosterContext() {
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false }; 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() { 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; if (_accountModalBusy) return;
const ctx = _determineHosterContext(); const ctx = _determineHosterContext();
@ -4476,35 +4467,13 @@ async function saveAccount() {
return; 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); 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; const mySession = _accountModalSession;
_accountModalBusy = true; _accountModalBusy = true;
_accountModalBusySession = mySession;
saveBtn.disabled = true; 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'; statusEl.className = 'account-modal-status checking';
const otpInput = document.getElementById('accField_otp'); const otpInput = document.getElementById('accField_otp');
@ -4518,94 +4487,86 @@ async function saveAccount() {
otp otp
}; };
let row; let result;
try { try {
row = await window.api.validateCredentials(payload); result = await window.AccountSubmit.submitValidatedAccount({
} catch (err) { validate: () => window.api.validateCredentials(payload),
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' }; commit: (validation) => _commitAccount(ctx, creds, validation),
} finally { isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
if (mySession === _accountModalSession) { });
_accountModalBusy = false; } catch (error) {
if (saveBtn) saveBtn.disabled = false; result = { status: 'error', error };
}
} }
// Stale response — modal was closed/reopened while we awaited. Drop it. const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
if (mySession !== _accountModalSession) return; if (result.status === 'committed' && current) {
const validation = result.validation || {};
if (row && row.status === 'otp_required') { statusEl.textContent = validation.status === 'warn'
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; ? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
statusEl.className = 'account-modal-status error'; : validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
_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'; statusEl.className = 'account-modal-status ok';
_hideOtpField(); _hideOtpField();
_validatedCreds = { saveBtn.textContent = ctx.isEdit ? 'Gespeichert' : 'Angelegt';
hosterName: ctx.hosterName, saveBtn.disabled = true;
authType: ctx.authType, if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
snapshot: snapshotKey, _autoCloseTimer = setTimeout(() => {
status: row.status, _autoCloseTimer = null;
message: row.message || '' closeAccountModal();
}; }, 600);
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
return; 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.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
} }
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) { function _copyHosterTree(hosters) {
// Persist the validated creds to config.hosters and close the modal. By the const candidate = {};
// time we reach this function the validate-credentials IPC has already for (const [name, accounts] of Object.entries(hosters || {})) {
// returned ok/warn for these exact creds, so we skip a redundant re-check. candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
let accountId; }
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = []; 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) { if (ctx.isEdit) {
accountId = ctx.accountId; const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId); if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
if (idx >= 0) { candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
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;
}
} else { } else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 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 }); await window.api.saveConfig({ hosters: candidateHosters });
// Skip the redundant await getConfig() — the in-memory state is the source config.hosters = candidateHosters;
// of truth for what we just wrote, decrypted creds didn't change, and the accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
// round-trip was the main lag source on add/delete.
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); 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) { if (ctx.isEdit) {
updateAccountCard(accountId); updateAccountCard(accountId);
} else { } else {
renderAccounts(); renderAccounts();
} }
renderHosterSummary(); 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() { function _showOtpField() {
@ -5170,6 +5131,7 @@ function setupListeners() {
// Account hoster select change → update credential fields // Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => { document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
_invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login'; const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
@ -5180,15 +5142,6 @@ function setupListeners() {
input.type = input.type === 'password' ? 'text' : 'password'; 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(); _wireCredFieldInvalidation();
}); });

View File

@ -421,6 +421,7 @@
<script src="../lib/throttled-cache.js"></script> <script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script> <script src="../lib/coalesced-set.js"></script>
<script src="../lib/throttle-timer.js"></script> <script src="../lib/throttle-timer.js"></script>
<script src="account-submit.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -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 { 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 ---- test('ok validates and commits exactly once in one submission', async () => {
// These mirror the production code exactly so the tests serve as both a guard let validations = 0;
// and executable spec for what saveAccount() must do. let commits = 0;
const result = await submitValidatedAccount({
function credsSnapshotKey(authType, creds) { validate: async () => {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`; validations++;
return `api:${creds.apiKey || ''}`; return { status: 'ok', message: 'Login erfolgreich' };
}
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 }) commit: async () => {
}); commits++;
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false }; },
const creds = { username: 'u', password: 'p' }; isCurrent: () => true
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 () => { assert.equal(result.status, 'committed');
const persistCalls = []; assert.equal(validations, 1);
let validateCount = 0; assert.equal(commits, 1);
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)', () => { test('warn validates and commits exactly once in one submission', async () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential. let commits = 0;
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }), const validation = { status: 'warn', message: 'Login mit Warnung' };
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' })); const result = await submitValidatedAccount({
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }), validate: async () => validation,
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case commit: async (received) => {
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }), commits++;
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff assert.equal(received, validation);
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }), },
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' })); isCurrent: () => true
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
}); });
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => { assert.equal(result.status, 'committed');
// The per-hoster checkers in main.js read .username/.password/.apiKey directly. assert.equal(result.validation, validation);
// This guards the validate-credentials IPC contract from drifting. assert.equal(commits, 1);
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' }); });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p'); for (const status of ['error', 'skipped']) {
assert.equal(cfg.apiKey, ''); test(`${status} rejects without committing`, async () => {
assert.equal(cfg.enabled, true); let commits = 0;
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' }); const validation = { status, message: `${status} result` };
assert.equal(cfg2.apiKey, 'K'); const result = await submitValidatedAccount({
assert.equal(cfg2.username, ''); 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('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);
}); });