release: Multi-Hoster-Upload v2.0.1
This commit is contained in:
+195
-182
@@ -1,195 +1,208 @@
|
||||
// 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 {
|
||||
createAccountSubmitter,
|
||||
getAccountSubmitLabel,
|
||||
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('account submit labels stay exact for add, edit, and OTP retries', () => {
|
||||
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
|
||||
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
|
||||
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
|
||||
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
|
||||
});
|
||||
|
||||
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('close and reopen cannot start a second save while the first save is pending', async () => {
|
||||
const submitter = createAccountSubmitter();
|
||||
let current = true;
|
||||
let commits = 0;
|
||||
let applies = 0;
|
||||
let saveStarted;
|
||||
let finishSave;
|
||||
const started = new Promise(resolve => { saveStarted = resolve; });
|
||||
const saving = new Promise(resolve => { finishSave = resolve; });
|
||||
const first = submitter.submit({
|
||||
validate: async () => ({ status: 'ok' }),
|
||||
commit: async () => {
|
||||
commits++;
|
||||
saveStarted();
|
||||
await saving;
|
||||
return { accountId: 'first' };
|
||||
},
|
||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||
afterCommit: async () => {
|
||||
applies++;
|
||||
},
|
||||
isCurrent: () => current
|
||||
});
|
||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||
const creds = { username: 'u', password: 'p' };
|
||||
await sm.click(ctx, creds, ''); // first click → otp_required
|
||||
await sm.click(ctx, creds, '123456'); // retry with otp → ok
|
||||
await sm.click(ctx, creds); // final click → commit
|
||||
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
|
||||
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
|
||||
assert.deepEqual(
|
||||
sm.log().map(e => e.type),
|
||||
['otp-required', 'validated', 'persisted']
|
||||
);
|
||||
});
|
||||
|
||||
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
|
||||
const persistCalls = [];
|
||||
let validateCount = 0;
|
||||
const sm = makeStateMachine({
|
||||
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||
await started;
|
||||
current = false;
|
||||
const second = submitter.submit({
|
||||
validate: async () => ({ status: 'ok' }),
|
||||
commit: async () => {
|
||||
commits++;
|
||||
},
|
||||
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(second, null);
|
||||
assert.equal(submitter.isBusy(), true);
|
||||
finishSave();
|
||||
const result = await first;
|
||||
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.committed, true);
|
||||
assert.equal(commits, 1);
|
||||
assert.equal(applies, 1);
|
||||
assert.equal(submitter.isBusy(), false);
|
||||
});
|
||||
|
||||
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('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
|
||||
const expected = new Error('render failed');
|
||||
let saves = 0;
|
||||
let applies = 0;
|
||||
const result = await submitValidatedAccount({
|
||||
validate: async () => ({ status: 'ok' }),
|
||||
commit: async () => {
|
||||
saves++;
|
||||
return { accountId: 'saved-account' };
|
||||
},
|
||||
afterCommit: async () => {
|
||||
applies++;
|
||||
throw expected;
|
||||
},
|
||||
isCurrent: () => true
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'committed');
|
||||
assert.equal(result.value.accountId, 'saved-account');
|
||||
assert.equal(result.postCommitError, expected);
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(applies, 1);
|
||||
});
|
||||
|
||||
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('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' };
|
||||
},
|
||||
commit: async () => {
|
||||
commits++;
|
||||
},
|
||||
isCurrent: () => true
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'committed');
|
||||
assert.equal(validations, 1);
|
||||
assert.equal(commits, 1);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user