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