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:
parent
b075961802
commit
0652edf69f
29
renderer/account-submit.js
Normal file
29
renderer/account-submit.js
Normal 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);
|
||||
271
renderer/app.js
271
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 =>
|
||||
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
|
||||
).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();
|
||||
});
|
||||
|
||||
|
||||
@ -421,6 +421,7 @@
|
||||
<script src="../lib/throttled-cache.js"></script>
|
||||
<script src="../lib/coalesced-set.js"></script>
|
||||
<script src="../lib/throttle-timer.js"></script>
|
||||
<script src="account-submit.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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 })
|
||||
});
|
||||
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']
|
||||
);
|
||||
commit: async () => {
|
||||
commits++;
|
||||
},
|
||||
isCurrent: () => true
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(result.status, 'committed');
|
||||
assert.equal(validations, 1);
|
||||
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' }));
|
||||
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
|
||||
});
|
||||
|
||||
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, '');
|
||||
assert.equal(result.status, 'committed');
|
||||
assert.equal(result.validation, validation);
|
||||
assert.equal(commits, 1);
|
||||
});
|
||||
|
||||
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('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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user