diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1bf2a795ec..e963d0698f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -293,7 +293,13 @@ if(KPXC_FEATURE_NETWORK) networking/UpdateChecker.cpp gui/UpdateCheckDialog.cpp gui/IconDownloader.cpp - gui/IconDownloaderDialog.cpp) + gui/IconDownloaderDialog.cpp + gui/remote/CloudSyncPage.cpp + gui/remote/dropbox/DropboxCloudSyncPage.cpp + gui/remote/nextcloud/NextcloudCloudSyncPage.cpp + gui/remote/nextcloud/NextcloudCloudSyncPage.h + gui/remote/nextcloud/NextcloudCloudSyncPage.ui + gui/remote/DatabaseSettingsWidgetCloudSync.cpp) endif() add_subdirectory(cli) @@ -320,6 +326,11 @@ if(KPXC_FEATURE_FDOSECRETS) set(fdosecrets_LIB fdosecrets) endif() +add_subdirectory(remotesync) +# remotesync's foundation (provider abstraction + engine + command provider) +# is always built. +set(remotesync_LIB remotesync) + configure_file(config-keepassx.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-keepassx.h) configure_file(git-info.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/git-info.h) @@ -352,7 +363,8 @@ target_link_libraries(keepassxc_gui ${browser_LIB} ${fdosecrets_LIB} ${keeshare_LIB} - ${sshagent_LIB}) + ${sshagent_LIB} + ${remotesync_LIB}) if(APPLE) target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit") diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 5f57601411..7417284629 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -565,6 +565,7 @@ void Database::releaseData() m_data.clear(); m_metadata->clear(); + m_syncPreviousKey.reset(); // Reset and delete the root group auto oldGroup = setRootGroup(new Group()); @@ -959,6 +960,27 @@ bool Database::setKey(const QSharedPointer& key, return true; } +void Database::setSyncPreviousKey(const QSharedPointer& key) +{ + // Don't overwrite an existing snapshot: if the user does A->B->C without + // an intervening successful sync, the remote still holds A and that's + // the key we need to keep. + if (m_syncPreviousKey) { + return; + } + m_syncPreviousKey = key; +} + +QSharedPointer Database::syncPreviousKey() const +{ + return m_syncPreviousKey; +} + +void Database::clearSyncPreviousKey() +{ + m_syncPreviousKey.reset(); +} + QString Database::keyError() { return m_keyError; diff --git a/src/core/Database.h b/src/core/Database.h index 0d183e778b..71926b4016 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -151,6 +151,13 @@ class Database : public ModifiableObject bool updateChangedTime = true, bool updateTransformSalt = false, bool transformKey = true); + /// Snapshot of the master key prior to a change-key, kept transiently so + /// the cloud-sync engine can unlock the remote DB (which still holds the + /// old key) and migrate it to the new key. Cleared on successful sync + /// upload, on releaseData (lock/close), and on destruction. Not persisted. + void setSyncPreviousKey(const QSharedPointer& key); + QSharedPointer syncPreviousKey() const; + void clearSyncPreviousKey(); QString keyError(); QByteArray challengeResponseKey() const; bool challengeMasterSeed(const QByteArray& masterSeed); @@ -253,6 +260,7 @@ public slots: bool m_hasNonDataChange = false; QString m_keyError; bool m_isTemporaryDatabase = false; + QSharedPointer m_syncPreviousKey; QStringList m_commonUsernames; QStringList m_tagList; diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 6e880c2e57..b15f79db1a 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -60,6 +60,14 @@ #include "remote/RemoteHandler.h" #include "remote/RemoteSettings.h" +#include "remotesync/RemoteSyncParams.h" +#include "remotesync/RemoteSyncProvider.h" +#include "remotesync/SyncEngine.h" +#include +#include +#include +#include + #ifdef KPXC_FEATURE_NETWORK #include "gui/IconDownloaderDialog.h" #endif @@ -102,6 +110,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) m_db->open(nullptr); } + m_messageWidget->setObjectName("databaseWidgetMessageWidget"); m_messageWidget->setHidden(true); auto mainLayout = new QVBoxLayout(); @@ -218,6 +227,12 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) connect(m_editGroupWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); connect(m_reportsDialog, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); connect(m_databaseSettingDialog, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); +#ifdef KPXC_FEATURE_NETWORK + connect(m_databaseSettingDialog, &DatabaseSettingsDialog::cloudSyncTriggered, + this, &DatabaseWidget::syncWithCloud); + // Sync-on-open: trigger cloud sync after database unlock (self→self, wired once in constructor) + connect(this, &DatabaseWidget::databaseUnlocked, this, &DatabaseWidget::onDatabaseUnlockedTriggerSync); +#endif connect(m_databaseOpenWidget, SIGNAL(dialogFinished(bool)), SLOT(loadDatabase(bool))); connect(this, SIGNAL(currentChanged(int)), SLOT(emitCurrentModeChanged())); connect(this, SIGNAL(requestGlobalAutoType(const QString&)), parent, SLOT(performGlobalAutoType(const QString&))); @@ -1145,78 +1160,305 @@ int DatabaseWidget::addChildWidget(QWidget* w) void DatabaseWidget::syncWithRemote(const RemoteParams* params) { + initSyncEngine(); + + // Command-type syncs have no token storage — clear cloud sync config name + // to prevent stale value from triggering token persistence in refreshedTokenData. + m_currentSyncConfigName.clear(); + + // Convert RemoteParams to typed CommandSyncParams. + // Stored as members so they outlive the synchronous-but-reentrant sync call. + // Refuse re-entry while another sync is in progress. Without this guard, + // a lambda fired off databaseSyncInProgress (or any nested-event-loop + // continuation) that calls syncWithRemote/syncWithCloud can call + // m_syncParams.reset(...), freeing the cmdParams pointer this function + // still holds and uses below -- a UAF on cmdParams->type at line ~1208. + // Auto-sync trigger slots (onDatabaseSavedTriggerSync / + // onDatabaseUnlockedTriggerSync) already check m_syncInProgress; this + // covers the manual menu paths and any other direct entry point. + if (m_syncInProgress) { + return; + } + + auto* cmdParams = new CommandSyncParams(); + cmdParams->type = "command"; + cmdParams->name = params->name; + cmdParams->downloadCommand = params->downloadCommand; + cmdParams->downloadInput = params->downloadInput; + cmdParams->downloadTimeoutMsec = params->downloadTimeoutMsec; + cmdParams->uploadCommand = params->uploadCommand; + cmdParams->uploadInput = params->uploadInput; + cmdParams->uploadTimeoutMsec = params->uploadTimeoutMsec; + m_syncParams.reset(cmdParams); + + m_currentSyncName = params->name; + m_pendingSyncKind = PendingSyncKind::Command; + m_lastSyncErrorKind = RemoteSyncProvider::ErrorKind::Other; + m_syncInProgress = true; setDisabled(true); emit databaseSyncInProgress(); - QScopedPointer remoteHandler(new RemoteHandler(this)); - RemoteHandler::RemoteResult result; - result.success = false; - result.errorMessage = tr("Remote Sync did not contain any download or upload commands."); - - // Download the database - if (!params->downloadCommand.isEmpty()) { - emit updateSyncProgress(25, tr("Downloading...")); - // Start a download first then merge and upload in the callback - result = remoteHandler->download(params); - if (result.success) { - QString error; - QSharedPointer remoteDb = QSharedPointer::create(); - if (!remoteDb->open(result.filePath, m_db->key(), &error)) { - // Failed to open downloaded remote database with same key - // Unlock downloaded remote database via dialog - syncDatabaseWithLockedDatabase(result.filePath, params); - return; - } - remoteDb->markAsTemporaryDatabase(); - if (!syncWithDatabase(remoteDb, error)) { - // Something failed during the sync process - result.success = false; - result.errorMessage = error; - } - } + // Parent the provider to `this` (DatabaseWidget) rather than the engine: the + // QScopedPointer m_syncProvider already owns lifetime, and parenting to the + // engine creates a use-after-free hazard if the engine is reset before the QSP + // (any future engine.reset() would Qt-delete the provider out from under the + // QSP, which still holds the dangling pointer until its next reset/dtor). + m_syncProvider.reset(RemoteSyncProvider::create(cmdParams->type, this)); + if (!m_syncProvider) { + m_syncInProgress = false; + m_pendingSyncKind = PendingSyncKind::None; + setDisabled(false); + showErrorMessage(tr("Unknown sync provider type: %1").arg(cmdParams->type)); + return; } - uploadAndFinishSync(params, result); + if (!m_syncEngine->startSync(m_syncProvider.data(), m_syncParams.data())) { + // Sync already in progress -- syncError signal handles messaging + m_syncInProgress = false; + m_pendingSyncKind = PendingSyncKind::None; + setDisabled(false); + } } -void DatabaseWidget::syncDatabaseWithLockedDatabase(const QString& filePath, const RemoteParams* params) +void DatabaseWidget::syncWithCloud() { - // disconnect any previously added slots to these signal - disconnect(this, &DatabaseWidget::databaseSyncUnlocked, nullptr, nullptr); - disconnect(this, &DatabaseWidget::databaseSyncUnlockFailed, nullptr, nullptr); +#ifdef KPXC_FEATURE_NETWORK + // Symmetric with syncWithRemote: refuse re-entry. Prevents the inner sync + // from clobbering m_syncProvider / m_syncParams while the outer call is + // still using them, and avoids two concurrent SyncEngine flows. + if (m_syncInProgress) { + return; + } - connect(this, &DatabaseWidget::databaseSyncUnlocked, [this, params](const RemoteHandler::RemoteResult& result) { - uploadAndFinishSync(params, result); - }); - connect(this, &DatabaseWidget::databaseSyncUnlockFailed, [this, params](const RemoteHandler::RemoteResult& result) { - finishSync(params, result); - }); + const QString type = m_remoteSettings->activeProvider(); + if (type.isEmpty()) { + return; // No active cloud provider configured + } + const QString configName = type + QStringLiteral("-default"); + QJsonObject config = m_remoteSettings->getProviderConfig(type, configName); + + initSyncEngine(); - emit unlockDatabaseInDialogForSync(filePath); + // Construct the provider first so we can dispatch isAuthorized + buildParamsFromConfig + displayName. + // Parented to `this`, not m_syncEngine — see use-after-free note at the command-sync site above. + m_syncProvider.reset(RemoteSyncProvider::create(type, this)); + if (!m_syncProvider) { + showErrorMessage(tr("Unknown sync provider type: %1").arg(type)); + return; + } + if (!m_syncProvider->isAuthorized(config)) { + return; // Provider says config doesn't represent an authorized state + } + + // Build provider-specific params via virtual dispatch (no inline construction here). + m_syncParams.reset(m_syncProvider->buildParamsFromConfig(config)); + + m_currentSyncName = m_syncProvider->displayName(); // User-visible provider name + m_currentSyncConfigName = configName; // Config key for token refresh + m_pendingSyncKind = PendingSyncKind::Cloud; + m_lastSyncErrorKind = RemoteSyncProvider::ErrorKind::Other; + m_syncInProgress = true; + setDisabled(true); + emit databaseSyncInProgress(); + + if (!m_syncEngine->startSync(m_syncProvider.data(), m_syncParams.data())) { + m_syncInProgress = false; + m_pendingSyncKind = PendingSyncKind::None; + setDisabled(false); + } +#endif } -void DatabaseWidget::uploadAndFinishSync(const RemoteParams* params, RemoteHandler::RemoteResult result) +#ifdef KPXC_FEATURE_NETWORK +QJsonObject DatabaseWidget::getCloudSyncConfig() const { - QScopedPointer remoteHandler(new RemoteHandler(this)); - if (result.success && !params->uploadCommand.isEmpty()) { - emit updateSyncProgress(75, tr("Uploading...")); - result = remoteHandler->upload(result.filePath, params); + const QString type = m_remoteSettings->activeProvider(); + if (type.isEmpty()) { + return QJsonObject{}; } + return m_remoteSettings->getProviderConfig(type, type + QStringLiteral("-default")); +} - finishSync(params, result); +QString DatabaseWidget::getCloudSyncProviderDisplayName() const +{ + // Use the active provider type rather than m_syncProvider, which is only + // populated after a sync has been kicked off — the menu / status surface + // needs the display name as soon as a provider is configured. + const QString type = m_remoteSettings->activeProvider(); + if (type.isEmpty()) { + return QString{}; + } + QScopedPointer provider(RemoteSyncProvider::create(type)); + return provider ? provider->displayName() : QString{}; } -void DatabaseWidget::finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result) +bool DatabaseWidget::isCloudSyncAuthorized() const { - setDisabled(false); - emit updateSyncProgress(-1, ""); - if (result.success) { - emit databaseSyncCompleted(params->name); - showMessage(tr("Remote sync '%1' completed successfully!").arg(params->name), MessageWidget::Positive, false); - } else { - emit databaseSyncFailed(params->name, result.errorMessage); - showErrorMessage(tr("Remote sync '%1' failed: %2").arg(params->name, result.errorMessage)); + const QString type = m_remoteSettings->activeProvider(); + if (type.isEmpty()) { + return false; + } + const QJsonObject config = m_remoteSettings->getProviderConfig(type, type + QStringLiteral("-default")); + if (config.isEmpty()) { + return false; + } + QScopedPointer provider(RemoteSyncProvider::create(type)); + return provider && provider->isAuthorized(config); +} + +RemoteSyncProvider::ErrorKind DatabaseWidget::classifyCloudSyncError(const QString& errorMessage) const +{ + // Prefer the kind cached from the last sync's failure result -- the + // provider set it from a machine-readable signal (HTTP status / OAuth + // error code) when the error was produced. Falling back to substring- + // matching the (localized) error message is the path used by + // command/script sync, which carries no kind on its shell stdout. + if (m_lastSyncErrorKind != RemoteSyncProvider::ErrorKind::Other) { + return m_lastSyncErrorKind; + } + return m_syncProvider->classifyError(errorMessage); +} + +void DatabaseWidget::onDatabaseSavedTriggerSync() +{ + // Don't trigger if sync is already running (prevents infinite sync-save loop) + if (m_syncInProgress) { + return; + } + if (m_syncEngine && m_syncEngine->state() != SyncEngine::State::Idle) { + return; + } + if (!isCloudSyncAuthorized()) { + return; + } + const QString type = m_remoteSettings->activeProvider(); + const QJsonObject config = m_remoteSettings->getProviderConfig(type, type + QStringLiteral("-default")); + if (!config.value(QStringLiteral("syncOnSave")).toBool(true)) { + return; } + syncWithCloud(); +} + +void DatabaseWidget::onDatabaseUnlockedTriggerSync() +{ + if (m_syncInProgress) { + return; + } + if (!isCloudSyncAuthorized()) { + return; + } + const QString type = m_remoteSettings->activeProvider(); + const QJsonObject config = m_remoteSettings->getProviderConfig(type, type + QStringLiteral("-default")); + if (!config.value(QStringLiteral("syncOnOpen")).toBool(true)) { + return; + } + // Defer sync after unlock flow completes (avoids interference with processAutoOpen) + QTimer::singleShot(0, this, &DatabaseWidget::syncWithCloud); +} +#endif + +void DatabaseWidget::initSyncEngine() +{ + if (m_syncEngine) { + // If the engine is stuck in a non-Idle state (e.g., after remoteDbNeedsKey + // where sync was abandoned), destroy and recreate it. + if (m_syncEngine->state() != SyncEngine::State::Idle) { + // Restore widget state before destroying — no syncFinished/syncError will fire + m_syncInProgress = false; + setDisabled(false); + emit updateSyncProgress(-1, ""); + m_syncEngine.reset(); + } else { + return; + } + } + + // Route the engine's local save through performSave so cloud sync inherits + // the user's normal save policy (UseAtomicSaves / UseDirectWriteSaves / + // BackupBeforeSave / MainWindow lockout). Capturing `this` is safe: the + // engine is parented to `this` and never outlives the widget. + auto saveFn = [this](QString& errorMessage) -> bool { + return performSave(errorMessage, /*fileName=*/QString()); + }; + m_syncEngine.reset(new SyncEngine(m_db, saveFn, this)); + + connect(m_syncEngine.data(), &SyncEngine::syncProgress, this, [this](int pct, const QString& msg) { + emit updateSyncProgress(pct, msg); + }); + + connect(m_syncEngine.data(), &SyncEngine::syncFinished, this, [this](bool success, const QString& msg) { + m_syncInProgress = false; + m_pendingSyncKind = PendingSyncKind::None; + // Cache the provider-reported error kind so classifyCloudSyncError + // (invoked from MainWindow's syncFailed banner code) dispatches on a + // machine-readable signal instead of substring-matching the localized + // error message. + m_lastSyncErrorKind = + m_syncEngine ? m_syncEngine->lastErrorKind() : RemoteSyncProvider::ErrorKind::Other; + setDisabled(false); + emit updateSyncProgress(-1, ""); + const bool adopted = m_remoteKeyAdoptedDuringSync; + m_remoteKeyAdoptedDuringSync = false; + if (success) { + emit databaseSyncCompleted(m_currentSyncName); + if (adopted) { + showMessage(tr("Master key from remote was more recent and was applied to " + "current database. Remote sync '%1' completed.") + .arg(m_currentSyncName), + MessageWidget::Positive, + false); + } else { + showMessage(tr("Remote sync '%1' completed!").arg(m_currentSyncName), + MessageWidget::Positive, + false); + } + } else { + emit databaseSyncFailed(m_currentSyncName, msg); + if (adopted) { + showErrorMessage(tr("Master key from remote was applied to current database, " + "but remote sync '%1' failed: %2") + .arg(m_currentSyncName, msg)); + } else { + showErrorMessage(tr("Remote sync '%1' failed: %2").arg(m_currentSyncName, msg)); + } + } + }); + + connect(m_syncEngine.data(), &SyncEngine::syncError, this, [this](const QString& error) { + // Overlap rejection -- re-enable and show error + m_pendingSyncKind = PendingSyncKind::None; + m_syncInProgress = false; + setDisabled(false); + showErrorMessage(error); + }); + + connect(m_syncEngine.data(), &SyncEngine::remoteDbNeedsKey, this, [this](const QString& filePath) { + // Remote DB couldn't be opened with the current key (and the + // syncPreviousKey fallback didn't match either). Open the unlock + // dialog; on success, syncUnlockedDatabase() captures the user- + // provided key as syncPreviousKey and re-triggers the sync via the + // path matching m_pendingSyncKind (Command vs Cloud). On cancel, + // unlockDatabase() removes the orphaned temp file. + // + // m_syncParams / m_syncProvider / m_syncEngine are kept alive across + // the unlock dialog so a Command-kind resume still has its original + // RemoteParams. They are reset/rebuilt at the resume site. + m_syncInProgress = false; + setDisabled(false); + emit updateSyncProgress(-1, ""); + m_pendingRemoteSyncFilePath = filePath; + emit unlockDatabaseInDialogForSync(filePath); + }); + + connect(m_syncEngine.data(), &SyncEngine::refreshedTokenData, this, [this](const QString& tokenDataJson) { + // Thin dispatch -- provider's persistRefreshedTokens owns the parse + persist. + // Command-type syncs (no m_currentSyncConfigName) and sessions without an + // active provider are skipped here. + if (m_currentSyncConfigName.isEmpty() || !m_syncProvider) { + return; + } + m_syncProvider->persistRefreshedTokens(tokenDataJson, m_currentSyncConfigName, m_remoteSettings.data()); + }); } QList DatabaseWidget::getRemoteParams() const @@ -1311,6 +1553,18 @@ void DatabaseWidget::connectDatabaseSignals() connect(m_db.data(), &Database::databaseFileChanged, this, &DatabaseWidget::reloadDatabaseFile); connect(m_db.data(), &Database::databaseNonDataChanged, this, &DatabaseWidget::databaseNonDataChanged); connect(m_db.data(), &Database::databaseNonDataChanged, this, &DatabaseWidget::onDatabaseNonDataChanged); + +#ifdef KPXC_FEATURE_NETWORK + // Sync-on-save: trigger cloud sync after successful save + // Sync-on-save: run cloud sync after the save fully completes. + // QueuedConnection ensures the sync starts in the next event loop iteration, + // after the save operation has released its lock on the database file. + connect( + m_db.data(), &Database::databaseSaved, this, &DatabaseWidget::onDatabaseSavedTriggerSync, Qt::QueuedConnection); + // Note: sync-on-open (databaseUnlocked→onDatabaseUnlockedTriggerSync) is connected + // once in the constructor — NOT here, since both endpoints are `this` and the + // connection would accumulate on every replaceDatabase/connectDatabaseSignals call. +#endif } void DatabaseWidget::loadDatabase(bool accepted) @@ -1408,55 +1662,109 @@ void DatabaseWidget::mergeDatabase(bool accepted) void DatabaseWidget::syncUnlockedDatabase(bool accepted) { - if (accepted) { - if (!m_db) { - showMessage(tr("No current database."), MessageWidget::Error); - return; - } - - auto* senderDialog = qobject_cast(sender()); - - Q_ASSERT(senderDialog); - if (!senderDialog) { - return; - } - auto destinationDb = senderDialog->database(); + switchToMainView(); - if (!destinationDb) { - showMessage(tr("No source database, nothing to do."), MessageWidget::Error); - return; - } +#ifdef KPXC_FEATURE_NETWORK + // The temp file from the failed-merge attempt is no longer needed -- + // the upcoming fresh sync will download into its own new temp file. + QFile::remove(m_pendingRemoteSyncFilePath); + m_pendingRemoteSyncFilePath.clear(); - RemoteHandler::RemoteResult result; - QString error; - result.success = syncWithDatabase(destinationDb, error); - result.errorMessage = error; - result.filePath = destinationDb->filePath(); + // Capture the resume kind and clear it before any branch so a re-entrant + // call into one of the resume paths sees a clean slate. + const PendingSyncKind kind = m_pendingSyncKind; + m_pendingSyncKind = PendingSyncKind::None; - emit databaseSyncUnlocked(result); + if (!accepted) { + // Drop the preserved sync context -- the user cancelled the unlock. + m_syncParams.reset(); + m_syncProvider.reset(); + m_syncEngine.reset(); + return; } - switchToMainView(); -} - -bool DatabaseWidget::syncWithDatabase(const QSharedPointer& otherDb, QString& error) -{ - emit updateSyncProgress(50, tr("Syncing...")); - Merger firstMerge(m_db.data(), otherDb.data()); - Merger secondMerge(otherDb.data(), m_db.data()); - auto changeList = firstMerge.merge() + secondMerge.merge(); - if (!changeList.isEmpty()) { - // Save synced databases - if (!save()) { - error = tr("Error while saving database %1: %2").arg(m_db->filePath(), error); - return false; - } - if (!otherDb->save(Database::Atomic, {}, &error)) { - error = tr("Error while saving database %1: %2").arg(otherDb->filePath(), error); - return false; - } + // sender() is the DatabaseOpenDialog that just emitted dialogFinished + // -- unlockDatabase already cast and intent-checked it before routing + // here, so the cast cannot fail. + auto* senderDialog = qobject_cast(sender()); + auto remoteDb = senderDialog->database(); + const auto remoteKey = remoteDb->key(); + const auto remoteKeyChangedAt = remoteDb->metadata()->databaseKeyChanged(); + const auto localKeyChangedAt = m_db->metadata()->databaseKeyChanged(); + + if (kind == PendingSyncKind::Cloud) { + // "Newer wins" reconciliation -- the master-key change with the more + // recent timestamp is treated as the user's most recent intent. See + // TestDatabase::testSyncResolveByTimestamp for the building-block + // invariants this dispatch relies on (notably: setKey(updateChangedTime + // =false) preserves the timestamp, so the adopt arm doesn't bump it). + // + // Newer-wins adoption is Cloud-only: command/script sync keeps the + // simpler behavior (just feed the entered key as syncPreviousKey and + // re-run the sync), so a user who never opted into cloud sync can't + // have their local master key silently replaced by whatever the + // script-sync remote happens to hold. + if (remoteKeyChangedAt > localKeyChangedAt) { + // Remote key is newer -- adopt it locally and inherit the remote's + // change timestamp so the next sync sees a stable value (otherwise + // the local would always look "newer than remote" and we'd flip + // back, clobbering whatever migrated us in the first place). + m_db->setKey(remoteKey, false, false, false); + m_db->metadata()->setDatabaseKeyChanged(remoteKeyChangedAt); + m_db->clearSyncPreviousKey(); + m_remoteKeyAdoptedDuringSync = true; + } else { + // Local key is newer (or equal -- tiebreak to local since we're + // initiating the sync). Push local; the dialog-typed remote key + // becomes the previousKey so doMerge unlocks the remote, then + // doUpload writes the local-current key. + m_db->clearSyncPreviousKey(); + m_db->setSyncPreviousKey(remoteKey); + } + // Re-read config from RemoteSettings; the active provider may have + // changed between sync start and unlock-dialog completion. + m_syncParams.reset(); + m_syncProvider.reset(); + m_syncEngine.reset(); + syncWithCloud(); + } else if (kind == PendingSyncKind::Command) { + // Command/script sync: feed the unlock key as previousKey so the + // SyncEngine's doMerge retry opens the remote on the next attempt, + // then re-trigger the same sync via its preserved CommandSyncParams. + m_db->clearSyncPreviousKey(); + m_db->setSyncPreviousKey(remoteKey); + + // Rebuild a transient RemoteParams view over the preserved + // CommandSyncParams so syncWithRemote's existing entry-point handles + // engine + provider + state setup uniformly. The kind/params pair + // is set atomically at every sync entry, so kind == Command + // (gated above) implies m_syncParams holds CommandSyncParams. + auto* cmd = static_cast(m_syncParams.data()); + RemoteParams resume; + resume.name = cmd->name; + resume.downloadCommand = cmd->downloadCommand; + resume.downloadInput = cmd->downloadInput; + resume.downloadTimeoutMsec = cmd->downloadTimeoutMsec; + resume.uploadCommand = cmd->uploadCommand; + resume.uploadInput = cmd->uploadInput; + resume.uploadTimeoutMsec = cmd->uploadTimeoutMsec; + // syncWithRemote builds a fresh CommandSyncParams + provider + engine, + // so dropping the preserved ones first avoids a stray double-owner + // when the QScopedPointer's reset assigns the new instances. + m_syncParams.reset(); + m_syncProvider.reset(); + m_syncEngine.reset(); + syncWithRemote(&resume); + } else { + // No pending kind -- shouldn't happen if the lambda set it, but fall + // through cleanly rather than triggering an unrelated sync. + m_syncParams.reset(); + m_syncProvider.reset(); + m_syncEngine.reset(); } - return true; +#else + Q_UNUSED(accepted) +#endif } /** @@ -1473,6 +1781,19 @@ void DatabaseWidget::unlockDatabase(bool accepted) emit closeRequest(); } if (senderDialog && senderDialog->intent() == DatabaseOpenDialog::Intent::RemoteSync) { +#ifdef KPXC_FEATURE_NETWORK + // SyncEngine handed off ownership of the downloaded temp file + // when it emitted remoteDbNeedsKey; cancel means we won't + // resume sync, so clean it up now. Also drop the preserved + // sync context (params / provider / engine were kept alive for + // a potential resume). + QFile::remove(m_pendingRemoteSyncFilePath); + m_pendingRemoteSyncFilePath.clear(); + m_pendingSyncKind = PendingSyncKind::None; + m_syncParams.reset(); + m_syncProvider.reset(); + m_syncEngine.reset(); +#endif RemoteHandler::RemoteResult result; result.success = false; result.errorMessage = "Remote database unlock cancelled."; @@ -1665,6 +1986,14 @@ void DatabaseWidget::switchToRemoteSettings() m_databaseSettingDialog->showRemoteSettings(); } +#ifdef KPXC_FEATURE_NETWORK +void DatabaseWidget::switchToCloudSyncSettings() +{ + switchToDatabaseSettings(); + m_databaseSettingDialog->showCloudSyncSettings(); +} +#endif + #ifdef KPXC_FEATURE_BROWSER void DatabaseWidget::switchToPasskeys() { @@ -2016,6 +2345,14 @@ bool DatabaseWidget::lock() return isLocked(); } + // Prevents UAF when nested event loop in HttpRetryHelper pumps a lock action during sync. + if (m_syncInProgress) { + showMessage(tr("Cannot lock database while cloud sync is in progress. " + "Please wait for sync to finish or fail."), + MessageWidget::Warning); + return false; + } + // ignore when reloading if (m_reloading) { return false; diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 82a261e465..bd315844c6 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -27,6 +27,7 @@ #include "gui/MessageWidget.h" #include "gui/entry/EntryModel.h" #include "remote/RemoteHandler.h" +#include "remotesync/RemoteSyncProvider.h" // for RemoteSyncProvider::ErrorKind in accessor signature class DatabaseOpenDialog; class DatabaseOpenWidget; @@ -47,6 +48,8 @@ class TagView; class ElidedLabel; class RemoteSettings; struct RemoteParams; +class SyncEngine; +struct RemoteSyncParams; namespace Ui { @@ -129,8 +132,24 @@ class DatabaseWidget : public QStackedWidget void setSearchStringForAutoType(const QString& search); void syncWithRemote(const RemoteParams* params); - void syncDatabaseWithLockedDatabase(const QString& filePath, const RemoteParams* params); QList getRemoteParams() const; +#ifdef KPXC_FEATURE_NETWORK + void syncWithCloud(); + QJsonObject getCloudSyncConfig() const; + + /// Returns the active cloud sync provider's user-visible display name + /// from RemoteSyncProvider::displayName. Empty string if no provider is active. + QString getCloudSyncProviderDisplayName() const; + + /// True iff a cloud sync provider is configured and its persisted config + /// represents an authorized state per RemoteSyncProvider::isAuthorized. + /// Generic gate used by trigger handlers and the Database>Remote Sync menu. + bool isCloudSyncAuthorized() const; + + /// Classify a cloud-sync error message via the active provider's + /// classifyError virtual. Returns ErrorKind::Other if no provider is active. + RemoteSyncProvider::ErrorKind classifyCloudSyncError(const QString& errorMessage) const; +#endif signals: // relayed Database signals @@ -235,6 +254,9 @@ public slots: void switchToDatabaseReports(); void switchToDatabaseSettings(); void switchToRemoteSettings(); +#ifdef KPXC_FEATURE_NETWORK + void switchToCloudSyncSettings(); +#endif #ifdef KPXC_FEATURE_BROWSER void switchToPasskeys(); void showImportPasskeyDialog(bool isEntry = false); @@ -287,14 +309,15 @@ private slots: void unlockDatabase(bool accepted); void mergeDatabase(bool accepted); void syncUnlockedDatabase(bool accepted); - bool syncWithDatabase(const QSharedPointer& otherDb, QString& error); - void uploadAndFinishSync(const RemoteParams* params, RemoteHandler::RemoteResult result); - void finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result); void emitCurrentModeChanged(); // Database autoreload slots void reloadDatabaseFile(bool triggeredBySave); void restoreGroupEntryFocus(const QUuid& groupUuid, const QUuid& EntryUuid); void onConfigChanged(Config::ConfigKey key); +#ifdef KPXC_FEATURE_NETWORK + void onDatabaseSavedTriggerSync(); + void onDatabaseUnlockedTriggerSync(); +#endif private: int addChildWidget(QWidget* w); @@ -302,6 +325,7 @@ private slots: void openDatabaseFromEntry(const Entry* entry, bool inBackground = true); void performIconDownloads(const QList& entries, bool force = false, bool downloadInBackground = false); bool performSave(QString& errorMessage, const QString& fileName = {}); + void initSyncEngine(); QSharedPointer m_db; @@ -334,6 +358,38 @@ private slots: bool m_attemptingLock = false; QScopedPointer m_remoteSettings; + QScopedPointer m_syncEngine; + QScopedPointer m_syncProvider; + QScopedPointer m_syncParams; + QString m_currentSyncName; + QString m_currentSyncConfigName; // Config key for token-refresh persist; runtime-set per active + // provider (composed from RemoteSettings::activeProvider() + "-default"). + /// Temp file handed off by SyncEngine when the remote DB key didn't match. + /// Kept alive across the unlock dialog so DatabaseOpenDialog can read it; + /// removed when the dialog completes (success or cancel). + QString m_pendingRemoteSyncFilePath; + /// Sync kind that was in flight when remoteDbNeedsKey fired. Routes the + /// resume after unlock back to the correct entry point (command/script sync + /// preserves its RemoteParams; cloud sync re-reads its config). + enum class PendingSyncKind + { + None, + Command, + Cloud + }; + PendingSyncKind m_pendingSyncKind = PendingSyncKind::None; + /// One-shot flag set by syncUnlockedDatabase's adopt arm so the next + /// syncFinished handler can prepend the master-key adoption notice to + /// its banner. Cleared at consumption time. + bool m_remoteKeyAdoptedDuringSync = false; + bool m_syncInProgress = false; + /// ErrorKind from the most recent failed sync. Cached from + /// SyncEngine::lastErrorKind() on syncFinished so classifyCloudSyncError + /// returns the provider's source-of-truth classification instead of + /// substring-matching tr()'d strings (which break on localized builds). + /// Cleared at the start of each sync (syncInProgress / syncWithRemote / + /// syncWithCloud paths). + RemoteSyncProvider::ErrorKind m_lastSyncErrorKind = RemoteSyncProvider::ErrorKind::Other; // Search state QScopedPointer m_entrySearcher; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 9b39a62e4a..4c3b2c4726 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -23,10 +23,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -54,6 +56,11 @@ #include "keeshare/SettingsPageKeeShare.h" #include "keys/drivers/YubiKey.h" +#ifdef KPXC_FEATURE_NETWORK +#include "remotesync/RemoteSyncProvider.h" +#include "remotesync/SyncEngine.h" +#endif + #ifdef KPXC_FEATURE_UPDATES #include "gui/UpdateCheckDialog.h" #include "networking/UpdateChecker.h" @@ -650,7 +657,11 @@ MainWindow::MainWindow() m_actionMultiplexer.connect(SIGNAL(updateSyncProgress(int, QString)), this, SLOT(updateProgressBar(int, QString))); m_actionMultiplexer.connect(SIGNAL(databaseSyncInProgress()), this, SLOT(disableMenuAndToolbar())); m_actionMultiplexer.connect(SIGNAL(databaseSyncCompleted(QString)), this, SLOT(enableMenuAndToolbar())); - m_actionMultiplexer.connect(SIGNAL(databaseSyncFailed(QString, const QString)), this, SLOT(enableMenuAndToolbar())); + m_actionMultiplexer.connect(SIGNAL(databaseSyncFailed(QString, QString)), this, SLOT(enableMenuAndToolbar())); +#ifdef KPXC_FEATURE_NETWORK + m_actionMultiplexer.connect(SIGNAL(databaseSyncCompleted(QString)), this, SLOT(updateSyncStatusBar(QString))); + m_actionMultiplexer.connect(SIGNAL(databaseSyncFailed(QString, QString)), this, SLOT(updateSyncFailedStatusBar(QString, QString))); +#endif m_statusBarLabel = new QLabel(statusBar()); m_statusBarLabel->setObjectName("statusBarLabel"); statusBar()->addPermanentWidget(m_statusBarLabel); @@ -1219,9 +1230,28 @@ void MainWindow::updateRemoteSyncMenuEntries() auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); if (dbWidget) { - // Setup sync shortcut - auto action = m_ui->menuRemoteSync->addAction(tr("Setup Remote Sync…")); - connect(action, &QAction::triggered, dbWidget, &DatabaseWidget::switchToRemoteSettings); +#ifdef KPXC_FEATURE_NETWORK + // Cloud sync trigger -- only shown when configured and authorized + if (dbWidget->isCloudSyncAuthorized()) { + QString providerName = dbWidget->getCloudSyncProviderDisplayName(); + if (!providerName.isEmpty()) { + auto* triggerAction = new QAction(tr("Trigger %1 Sync").arg(providerName), m_ui->menuRemoteSync); + m_ui->menuRemoteSync->addAction(triggerAction); + connect(triggerAction, &QAction::triggered, dbWidget, &DatabaseWidget::syncWithCloud); + m_ui->menuRemoteSync->addSeparator(); + } + } +#endif + + // Script Sync opens command-based sync config + auto scriptAction = m_ui->menuRemoteSync->addAction(tr("Script Sync...")); + connect(scriptAction, &QAction::triggered, dbWidget, &DatabaseWidget::switchToRemoteSettings); + +#ifdef KPXC_FEATURE_NETWORK + // Cloud Sync opens provider-based cloud sync config + auto cloudAction = m_ui->menuRemoteSync->addAction(tr("Cloud Sync...")); + connect(cloudAction, &QAction::triggered, dbWidget, &DatabaseWidget::switchToCloudSyncSettings); +#endif m_ui->menuRemoteSync->addSeparator(); @@ -1590,6 +1620,16 @@ void MainWindow::updateProgressBar(int percentage, QString message) void MainWindow::updateEntryCountLabel() { + // Clear sync status on user action (naturally triggered by groupChanged, + // databaseModified, searchModeActivated, listModeActivated signals) + if (m_syncStatusShown) { + m_syncStatusShown = false; + statusBar()->setAutoFillBackground(false); + QPalette pal = statusBar()->palette(); + pal.setColor(QPalette::Window, palette().color(QPalette::Window)); + statusBar()->setPalette(pal); + } + auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); if (dbWidget && dbWidget->currentMode() == DatabaseWidget::Mode::ViewMode) { int numEntries = dbWidget->entryView()->model()->rowCount(); @@ -1599,6 +1639,48 @@ void MainWindow::updateEntryCountLabel() } } +#ifdef KPXC_FEATURE_NETWORK +void MainWindow::updateSyncStatusBar(const QString& syncName) +{ + QString time = QTime::currentTime().toString(QStringLiteral("h:mm AP")); + m_statusBarLabel->setText(tr("%1: Synced %2").arg(syncName, time)); + + // Clear any red background from previous failure + statusBar()->setAutoFillBackground(false); + QPalette pal = statusBar()->palette(); + pal.setColor(QPalette::Window, palette().color(QPalette::Window)); + statusBar()->setPalette(pal); + + m_syncStatusShown = true; +} + +void MainWindow::updateSyncFailedStatusBar(const QString& syncName, const QString& error) +{ + QString time = QTime::currentTime().toString(QStringLiteral("h:mm AP")); + m_statusBarLabel->setText(tr("%1: Failed Sync %2").arg(syncName, time)); + + // Red background for failure + statusBar()->setAutoFillBackground(true); + QPalette pal = statusBar()->palette(); + pal.setColor(QPalette::Window, QColor(Qt::red).lighter(160)); + statusBar()->setPalette(pal); + + m_syncStatusShown = true; + + // Auth failure detection: route through the active provider's classifyError + // virtual via DatabaseWidget accessor; banner uses runtime provider displayName. + auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); + if (dbWidget) { + auto kind = dbWidget->classifyCloudSyncError(error); + if (kind == RemoteSyncProvider::ErrorKind::AuthExpired || kind == RemoteSyncProvider::ErrorKind::AuthRevoked) { + const QString providerName = dbWidget->getCloudSyncProviderDisplayName(); + dbWidget->showErrorMessage( + tr("%1 authorization expired. Re-authorize in Database > Settings > Cloud Sync.").arg(providerName)); + } + } +} +#endif + void MainWindow::obtainContextFocusLock() { m_contextMenuFocusLock = true; diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 2fa874e0ad..416cb386ff 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -155,6 +155,10 @@ private slots: void enableMenuAndToolbar(); void disableMenuAndToolbar(); void clearSSHAgent(); +#ifdef KPXC_FEATURE_NETWORK + void updateSyncStatusBar(const QString& syncName); + void updateSyncFailedStatusBar(const QString& syncName, const QString& error); +#endif private: static const QString BaseWindowTitle; @@ -192,6 +196,7 @@ private slots: Q_DISABLE_COPY(MainWindow) + bool m_syncStatusShown = false; bool m_windowInformationRestored = false; bool m_appExitCalled = false; bool m_appExiting = false; diff --git a/src/gui/dbsettings/DatabaseSettingsDialog.cpp b/src/gui/dbsettings/DatabaseSettingsDialog.cpp index 15ff24c295..03a66d1449 100644 --- a/src/gui/dbsettings/DatabaseSettingsDialog.cpp +++ b/src/gui/dbsettings/DatabaseSettingsDialog.cpp @@ -24,6 +24,9 @@ #include "DatabaseSettingsWidgetBrowser.h" #endif #include "../remote/DatabaseSettingsWidgetRemote.h" +#ifdef KPXC_FEATURE_NETWORK +#include "../remote/DatabaseSettingsWidgetCloudSync.h" +#endif #include "DatabaseSettingsWidgetMaintenance.h" #include "keeshare/DatabaseSettingsWidgetKeeShare.h" #ifdef KPXC_FEATURE_FDOSECRETS @@ -51,8 +54,12 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) #endif , m_maintenanceWidget(new DatabaseSettingsWidgetMaintenance(this)) , m_remoteWidget(new DatabaseSettingsWidgetRemote(this)) +#ifdef KPXC_FEATURE_NETWORK + , m_cloudSyncWidget(new DatabaseSettingsWidgetCloudSync(this)) +#endif { connect(this, SIGNAL(accepted()), SLOT(save())); + connect(this, SIGNAL(apply()), SLOT(applySettings())); connect(this, SIGNAL(rejected()), SLOT(reject())); addPage(tr("General"), icons()->icon("preferences-other"), m_generalWidget); @@ -72,7 +79,15 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) m_securityTabWidget->setCurrentIndex(0); - addPage(tr("Remote Sync"), icons()->icon("remote-sync"), m_remoteWidget); + addPage(tr("Script Sync"), icons()->icon("remote-sync"), m_remoteWidget); +#ifdef KPXC_FEATURE_NETWORK + addPage(tr("Cloud Sync"), icons()->icon("remote-sync"), m_cloudSyncWidget); + // Relay cloud sync trigger from settings widget to parent + connect(m_cloudSyncWidget, &DatabaseSettingsWidgetCloudSync::cloudSyncTriggered, + this, &DatabaseSettingsDialog::cloudSyncTriggered); + connect(m_cloudSyncWidget, &DatabaseSettingsWidgetCloudSync::settingsModified, + this, [this] { setModified(true); }); +#endif #ifdef KPXC_FEATURE_BROWSER addPage(tr("Browser Integration"), icons()->icon("internet-web-browser"), m_browserWidget); @@ -101,6 +116,9 @@ void DatabaseSettingsDialog::load(const QSharedPointer& db) m_databaseKeyWidget->loadSettings(db); m_encryptionWidget->loadSettings(db); m_remoteWidget->loadSettings(db); +#ifdef KPXC_FEATURE_NETWORK + m_cloudSyncWidget->loadSettings(db); +#endif #ifdef KPXC_FEATURE_BROWSER m_browserWidget->loadSettings(db); #endif @@ -124,33 +142,47 @@ void DatabaseSettingsDialog::showDatabaseKeySettings(int index) void DatabaseSettingsDialog::showRemoteSettings() { - setCurrentPage(2); + setCurrentPage(pageIndex(m_remoteWidget)); } -void DatabaseSettingsDialog::save() +#ifdef KPXC_FEATURE_NETWORK +void DatabaseSettingsDialog::showCloudSyncSettings() +{ + setCurrentPage(pageIndex(m_cloudSyncWidget)); +} +#endif + +bool DatabaseSettingsDialog::saveAllSettings() { if (!m_generalWidget->saveSettings()) { setCurrentPage(0); - return; + return false; } if (!m_databaseKeyWidget->saveSettings()) { setCurrentPage(1); m_securityTabWidget->setCurrentIndex(0); - return; + return false; } if (!m_encryptionWidget->saveSettings()) { setCurrentPage(1); m_securityTabWidget->setCurrentIndex(1); - return; + return false; } if (!m_remoteWidget->saveSettings()) { - setCurrentPage(2); - return; + setCurrentPage(pageIndex(m_remoteWidget)); + return false; } +#ifdef KPXC_FEATURE_NETWORK + if (!m_cloudSyncWidget->saveSettings()) { + setCurrentPage(pageIndex(m_cloudSyncWidget)); + return false; + } +#endif + // Browser settings don't have anything to save m_keeShareWidget->saveSettings(); @@ -158,7 +190,20 @@ void DatabaseSettingsDialog::save() m_fdoSecretsWidget->saveSettings(); #endif - emit editFinished(true); + setModified(false); + return true; +} + +void DatabaseSettingsDialog::save() +{ + if (saveAllSettings()) { + emit editFinished(true); + } +} + +void DatabaseSettingsDialog::applySettings() +{ + saveAllSettings(); } void DatabaseSettingsDialog::reject() @@ -167,6 +212,9 @@ void DatabaseSettingsDialog::reject() m_databaseKeyWidget->discard(); m_encryptionWidget->discard(); m_remoteWidget->discard(); +#ifdef KPXC_FEATURE_NETWORK + m_cloudSyncWidget->discard(); +#endif #ifdef KPXC_FEATURE_BROWSER m_browserWidget->discard(); #endif diff --git a/src/gui/dbsettings/DatabaseSettingsDialog.h b/src/gui/dbsettings/DatabaseSettingsDialog.h index 057452ba61..f97b6be2d1 100644 --- a/src/gui/dbsettings/DatabaseSettingsDialog.h +++ b/src/gui/dbsettings/DatabaseSettingsDialog.h @@ -37,6 +37,9 @@ class DatabaseSettingsWidgetFdoSecrets; #endif class DatabaseSettingsWidgetMaintenance; class DatabaseSettingsWidgetRemote; +#ifdef KPXC_FEATURE_NETWORK +class DatabaseSettingsWidgetCloudSync; +#endif class QTabWidget; class DatabaseSettingsDialog : public EditWidget @@ -51,15 +54,23 @@ class DatabaseSettingsDialog : public EditWidget void load(const QSharedPointer& db); void showDatabaseKeySettings(int index = 0); void showRemoteSettings(); +#ifdef KPXC_FEATURE_NETWORK + void showCloudSyncSettings(); +#endif signals: void editFinished(bool accepted); +#ifdef KPXC_FEATURE_NETWORK + void cloudSyncTriggered(); +#endif private slots: void save(); + void applySettings(); void reject(); private: + bool saveAllSettings(); QSharedPointer m_db; QPointer m_generalWidget; QPointer m_securityTabWidget; @@ -74,6 +85,9 @@ private slots: #endif QPointer m_maintenanceWidget; QPointer m_remoteWidget; +#ifdef KPXC_FEATURE_NETWORK + QPointer m_cloudSyncWidget; +#endif }; #endif // KEEPASSXC_DATABASESETTINGSDIALOG_H diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp index b41ca1fe2b..ebf43b6c5e 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp @@ -24,6 +24,7 @@ #include "gui/databasekey/KeyFileEditWidget.h" #include "gui/databasekey/PasswordEditWidget.h" #include "gui/databasekey/YubiKeyEditWidget.h" +#include "gui/remote/RemoteSettings.h" #include "keys/ChallengeResponseKey.h" #include "keys/FileKey.h" #include "keys/PasswordKey.h" @@ -213,6 +214,20 @@ bool DatabaseSettingsWidgetDatabaseKey::saveSettings() return false; } + // Capture the current key BEFORE the swap so the cloud-sync engine can + // unlock the remote DB (which still holds the old key) on the next sync + // and migrate it to the new key without prompting the user. + // + // Gate the snapshot on sync actually being configured for this database: + // for users without any sync (the majority), there is no consumer for + // the previous key and retaining a CompositeKey in memory until lock / + // close is an unnecessary security exposure. + { + RemoteSettings remoteSettings(m_db); + if (remoteSettings.hasAnySync()) { + m_db->setSyncPreviousKey(m_db->key()); + } + } m_db->setKey(newKey, true, false, false); getQuickUnlock()->reset(m_db->publicUuid()); diff --git a/src/gui/remote/CloudSyncPage.cpp b/src/gui/remote/CloudSyncPage.cpp new file mode 100644 index 0000000000..5d17d535aa --- /dev/null +++ b/src/gui/remote/CloudSyncPage.cpp @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "CloudSyncPage.h" + +#include "dropbox/DropboxCloudSyncPage.h" +#include "nextcloud/NextcloudCloudSyncPage.h" + +#include + +CloudSyncPage::CloudSyncPage(QWidget* parent) + : QWidget(parent) +{ +} + +void CloudSyncPage::setRemoteSettings(RemoteSettings* /*settings*/) +{ + // Default: no-op. Subclasses that persist tokens override. +} + +void CloudSyncPage::setMutualExclusivityWarning(bool /*active*/) +{ + // Default: no-op. Subclasses that need to disable their fields override. +} + +QList CloudSyncPage::createBuiltinPages(QWidget* parent) +{ + QList pages; + + // The parent widget never has to learn about either concrete type -- + // it drives both pages through the abstract CloudSyncPage contract. + auto* dropbox = new DropboxCloudSyncPage(parent); + // Stable objectName so QObject::findChild lookups in tests / UI + // introspection work regardless of insertion order. + dropbox->setObjectName(QStringLiteral("dropboxPage")); + pages.append(dropbox); + + auto* nextcloud = new NextcloudCloudSyncPage(parent); + nextcloud->setObjectName(QStringLiteral("nextcloudPage")); + pages.append(nextcloud); + + // Sort alphabetically by providerDisplayName() so the dropdown order is + // independent of registration order. Provider names are ASCII, so + // QString::compare with Qt::CaseSensitive suffices (locale-independent). + // The sort lives ONLY in the factory; registerPage keeps m_pages and + // providerComboBox parallel via insertion order. + std::sort(pages.begin(), pages.end(), [](CloudSyncPage* a, CloudSyncPage* b) { + return QString::compare(a->providerDisplayName(), b->providerDisplayName(), Qt::CaseSensitive) < 0; + }); + + return pages; +} diff --git a/src/gui/remote/CloudSyncPage.h b/src/gui/remote/CloudSyncPage.h new file mode 100644 index 0000000000..55596cb3dc --- /dev/null +++ b/src/gui/remote/CloudSyncPage.h @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_CLOUDSYNCPAGE_H +#define KEEPASSX_CLOUDSYNCPAGE_H + +#include +#include +#include +#include + +class RemoteSettings; +class RemoteSyncProvider; + +/// Abstract base for per-provider settings sub-pages hosted by +/// DatabaseSettingsWidgetCloudSync's providerStackedWidget. Each concrete +/// subclass (DropboxCloudSyncPage, NextcloudCloudSyncPage) owns the +/// provider-specific widgets via its own .ui file. The parent widget drives +/// the page through this contract -- never via dynamic_cast. +class CloudSyncPage : public QWidget +{ + Q_OBJECT + +public: + explicit CloudSyncPage(QWidget* parent = nullptr); + ~CloudSyncPage() override = default; + + /// Factory for the built-in per-provider pages. Returns one + /// new-allocated page per registered provider. Caller takes ownership + /// of each returned page (typically by reparenting to a host QWidget). + /// The parent argument is forwarded to each page's constructor. + /// Defined out-of-line so the parent widget never directly names + /// the concrete subclasses. + static QList createBuiltinPages(QWidget* parent = nullptr); + + /// Inject the provider this page configures. Parent owns the provider + /// lifetime; subclasses must NOT take ownership (pointer is borrowed). + virtual void setProvider(RemoteSyncProvider* provider) = 0; + + /// Provider type tag (e.g. "dropbox", "nextcloud"). Used by the parent + /// to match the page to RemoteSettings::activeProvider() and to drive + /// RemoteSettings::getProviderConfig(type, ...) / + /// setProviderConfig(type, ...). + virtual QString providerType() const = 0; + + /// Provider display name forwarded from RemoteSyncProvider::displayName(). + /// Used by the parent to populate the provider dropdown. + virtual QString providerDisplayName() const = 0; + + /// Populate the page's fields from the persisted config object. + /// Called by parent when the user opens the dialog. + virtual void loadFromConfig(const QJsonObject& config) = 0; + + /// Serialize current field values to a config object. + /// Called by parent on Apply. + virtual QJsonObject saveToConfig() const = 0; + + /// Whether the user has edited any field since the last loadFromConfig. + /// Drives Apply-button-enabled state. + virtual bool isModified() const = 0; + + /// Inject the RemoteSettings instance the parent owns; pages use it to + /// persist token updates from refresh/auth flows. Pointer is borrowed; + /// parent retains ownership. Default: no-op (subclasses that don't + /// persist tokens can ignore). + virtual void setRemoteSettings(RemoteSettings* settings); + + /// Notify the page that the parent's mutual-exclusivity check (Script + /// Sync configured) is active and the page's fields should be disabled. + /// Default: no-op. + virtual void setMutualExclusivityWarning(bool active); + +signals: + /// Emitted when the user clicks the per-page Authorize button. + void requestAuthorize(); + + /// Emitted when the user clicks the per-page Remove button. + void requestRemove(); + + /// Emitted when the user clicks the per-page Test Connection button. + void requestTestConnection(); + + /// Emitted when the user clicks the per-page Sync Now button. + void requestSync(); + + /// Emitted when any field on the page changes (drives Apply enabled state). + void modified(); + + /// Forwarded to the parent's MessageWidget for status/error display. + /// `messageType` matches the KMessageWidget::MessageType enum (passed as + /// int to keep this header free of MessageWidget includes); valid values + /// are 0=Positive, 1=Information, 2=Warning, 3=Error. + /// Optional `disableAutoHide` overrides the default auto-hide policy. + void showMessage(const QString& text, int messageType, bool disableAutoHide = false); + + /// Emitted when the page wants the parent's messageWidget cleared/hidden. + void hideMessage(); + +private: + Q_DISABLE_COPY(CloudSyncPage) +}; + +#endif // KEEPASSX_CLOUDSYNCPAGE_H diff --git a/src/gui/remote/DatabaseSettingsWidgetCloudSync.cpp b/src/gui/remote/DatabaseSettingsWidgetCloudSync.cpp new file mode 100644 index 0000000000..1dd4cc026c --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetCloudSync.cpp @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DatabaseSettingsWidgetCloudSync.h" +#include "ui_DatabaseSettingsWidgetCloudSync.h" + +#include "RemoteSettings.h" +#include "gui/MessageWidget.h" + +#include + +DatabaseSettingsWidgetCloudSync::DatabaseSettingsWidgetCloudSync(QWidget* parent) + : DatabaseSettingsWidget(parent) + , m_remoteSettings(new RemoteSettings(nullptr, nullptr)) + , m_ui(new Ui::DatabaseSettingsWidgetCloudSync()) +{ + m_ui->setupUi(this); + m_ui->messageWidget->setHidden(true); + + // Register provider pages via the CloudSyncPage factory. The factory + // lives in CloudSyncPage.cpp so this file never names a concrete + // subclass. + for (auto* page : CloudSyncPage::createBuiltinPages(this)) { + registerPage(page); + } + + m_ui->providerStackedWidget->setCurrentIndex(0); + + // Connect provider selection + connect(m_ui->providerComboBox, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &DatabaseSettingsWidgetCloudSync::onProviderChanged); + + // Mirrors KeyComponentWidget::updateSize so the QStackedWidget reports the + // size of the current page only, not max-of-children. Otherwise hidden + // provider pages with larger sizeHints (e.g. Nextcloud's appPasswordGroupBox) + // leak vertical slack into the visible page's QGroupBox. + QTimer::singleShot(0, this, &DatabaseSettingsWidgetCloudSync::updateSize); +} + +DatabaseSettingsWidgetCloudSync::~DatabaseSettingsWidgetCloudSync() = default; + +void DatabaseSettingsWidgetCloudSync::registerPage(CloudSyncPage* page) +{ + m_pages.append(page); + m_ui->providerStackedWidget->addWidget(page); + m_ui->providerComboBox->addItem(page->providerDisplayName()); + + // Inject the concrete provider via the RemoteSyncProvider factory. The + // provider is parented to this widget so the QObject parent chain owns + // its lifetime; pages borrow the pointer. The widget never names a + // concrete provider class (the factory does). + page->setProvider(RemoteSyncProvider::create(page->providerType(), this)); + + // Forward page->modified() through to settingsModified for the dialog's + // dirty-flag tracking. + connect(page, &CloudSyncPage::modified, this, &DatabaseSettingsWidgetCloudSync::settingsModified); + + // Forward page-emitted message events to the parent's MessageWidget. + connect(page, &CloudSyncPage::showMessage, this, &DatabaseSettingsWidgetCloudSync::onPageShowMessage); + connect(page, &CloudSyncPage::hideMessage, this, &DatabaseSettingsWidgetCloudSync::onPageHideMessage); + + // Trigger Sync button on the page bubbles up through cloudSyncTriggered. + connect(page, &CloudSyncPage::requestSync, this, &DatabaseSettingsWidgetCloudSync::onPageRequestSync); + + // After a Remove the page reset its config; we don't need to react beyond + // what the page already did locally (it persists via RemoteSettings). +} + +CloudSyncPage* DatabaseSettingsWidgetCloudSync::activePage() const +{ + return m_pages.value(m_ui->providerComboBox->currentIndex(), nullptr); +} + +void DatabaseSettingsWidgetCloudSync::initialize() +{ + m_ui->messageWidget->setHidden(true); + m_remoteSettings->setDatabase(m_db); + + // Always reset per-page state to THIS database's stored config before + // any mutual-exclusivity gate runs. The parent dialog widget is reused + // across databases, so without this reset a subsequent Apply could + // serialize stale state (including OAuth tokens) from a prior database + // into the current database's CustomData. + for (auto* page : m_pages) { + page->setRemoteSettings(m_remoteSettings.data()); + } + for (auto* page : m_pages) { + QJsonObject config = m_remoteSettings->getProviderConfig(page->providerType(), + page->providerType() + QStringLiteral("-default")); + page->setMutualExclusivityWarning(false); + page->loadFromConfig(config); + } + + // Mutual-exclusivity gate: if Script Sync is configured, lock cloud-sync + // editing entirely (one or the other, not both). We've already loaded the + // current database's state above so the locked widgets show its config, + // not a previous database's residue. + m_lockedByScriptSync = hasScriptSyncConfig(); + if (m_lockedByScriptSync) { + m_ui->messageWidget->showMessage( + tr("Script Sync is configured for this database. Remove it in the Script Sync tab before setting up " + "Cloud Sync."), + MessageWidget::Warning, + MessageWidget::DisableAutoHide); + m_ui->messageWidget->setCloseButtonVisible(false); + for (auto* page : m_pages) { + page->setMutualExclusivityWarning(true); + } + m_ui->providerComboBox->setEnabled(false); + return; + } + + m_ui->providerComboBox->setEnabled(true); + + // Switch the combobox to the active provider, if one is set. For + // single-provider DBs the active-provider lazy default in RemoteSettings + // resolves to whichever page has stored credentials. + const QString active = m_remoteSettings->activeProvider(); + if (!active.isEmpty()) { + for (int i = 0; i < m_pages.size(); ++i) { + if (m_pages[i]->providerType() == active) { + m_ui->providerComboBox->setCurrentIndex(i); + m_ui->providerStackedWidget->setCurrentIndex(i); + break; + } + } + } +} + +void DatabaseSettingsWidgetCloudSync::uninitialize() +{ +} + +bool DatabaseSettingsWidgetCloudSync::saveSettings() +{ + // Init-time mutual-exclusivity gate: when Script Sync was configured + // when the dialog opened, the cloud-sync tab is locked read-only. Skip + // serialization entirely -- the page widgets are display-only at this + // point, and persisting their (just-loaded) state would re-stamp the + // database's CustomData without user intent. + if (m_lockedByScriptSync) { + return true; + } + + // Runtime mutual-exclusivity check: catches the case where the dialog + // opened with both modes empty (init lock = false on both sides) and the + // user committed a Script Sync entry in this same dialog session before + // we got here. DatabaseSettingsDialog::saveAllSettings runs the script + // widget before the cloud widget, so by the time we reach this point + // those changes are persisted in the database's CustomData. We query a + // fresh RemoteSettings instance because m_remoteSettings was snapshot + // when the dialog opened and would not reflect script changes committed + // mid-session. + if (m_db) { + RemoteSettings current(m_db); + if (!current.getAllRemoteParams().isEmpty()) { + m_ui->messageWidget->showMessage( + tr("Script Sync was added in this session. Remove it in the Script Sync tab before applying " + "Cloud Sync."), + MessageWidget::Error, + MessageWidget::DisableAutoHide); + return false; + } + } + + auto* active = activePage(); + if (!active) { + return true; + } + + // saveToConfig always emits at least the type/name tags. The page + // returns an empty QJsonObject when its fields are in a fresh-no-edit + // state to signal "skip persistence" -- without this, opening the + // dialog without touching anything would bloat KDBX files with empty + // per-provider records. + QJsonObject config = active->saveToConfig(); + if (config.isEmpty()) { + return true; + } + + const QString configKey = active->providerType() + QStringLiteral("-default"); + + // Single-provider model: a database has at most one cloud-sync provider + // configured at a time. Two cloud backends synchronizing the same .kdbx + // would diverge irreversibly (no merge protocol between Dropbox revisions + // and Nextcloud ETags), so Apply replaces the previous provider when the + // new page reaches an authorized state. Before that, we still want to + // preserve the user's draft (URLs typed, paths, etc.) without destroying + // a previously-working provider config -- otherwise trying out Nextcloud + // would wipe a working Dropbox before Nextcloud is ever authorized. + QScopedPointer probe(RemoteSyncProvider::create(active->providerType())); + const bool authorized = probe && probe->isAuthorized(config); + + if (authorized) { + for (auto* page : m_pages) { + if (page != active) { + m_remoteSettings->removeProviderConfig(page->providerType(), + page->providerType() + QStringLiteral("-default")); + // Reload the displaced page from the now-empty config so its + // UI reflects the wipe in the current dialog session. Without + // this the page still shows its old line-edit text, token + // status, and cached m_config -- visually contradicting the + // single-provider model the wipe just enforced, and leading + // users to believe the database wasn't actually wiped either. + page->loadFromConfig(QJsonObject{}); + } + } + m_remoteSettings->setActiveProvider(active->providerType()); + } + + m_remoteSettings->setProviderConfig(active->providerType(), configKey, config); + m_remoteSettings->saveSettings(); + return true; +} + +void DatabaseSettingsWidgetCloudSync::onProviderChanged(int index) +{ + m_ui->providerStackedWidget->setCurrentIndex(index); + updateSize(); +} + +void DatabaseSettingsWidgetCloudSync::updateSize() +{ + auto* stack = m_ui->providerStackedWidget; + for (int i = 0; i < stack->count(); ++i) { + QSizePolicy policy = stack->widget(i)->sizePolicy(); + policy.setVerticalPolicy(stack->currentIndex() == i ? QSizePolicy::Preferred : QSizePolicy::Ignored); + stack->widget(i)->setSizePolicy(policy); + } +} + +void DatabaseSettingsWidgetCloudSync::onPageShowMessage(const QString& text, int messageType, bool disableAutoHide) +{ + const auto type = static_cast(messageType); + if (disableAutoHide) { + m_ui->messageWidget->showMessage(text, type, MessageWidget::DisableAutoHide); + } else { + m_ui->messageWidget->showMessage(text, type); + } +} + +void DatabaseSettingsWidgetCloudSync::onPageHideMessage() +{ + m_ui->messageWidget->hideMessage(); +} + +void DatabaseSettingsWidgetCloudSync::onPageRequestSync() +{ + emit cloudSyncTriggered(); +} + +bool DatabaseSettingsWidgetCloudSync::hasScriptSyncConfig() const +{ + return !m_remoteSettings->getAllRemoteParams().isEmpty(); +} diff --git a/src/gui/remote/DatabaseSettingsWidgetCloudSync.h b/src/gui/remote/DatabaseSettingsWidgetCloudSync.h new file mode 100644 index 0000000000..24b7b96e67 --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetCloudSync.h @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_DATABASESETTINGSWIDGETCLOUDSYNC_H +#define KEEPASSX_DATABASESETTINGSWIDGETCLOUDSYNC_H + +#include "CloudSyncPage.h" +#include "RemoteHandler.h" +#include "gui/dbsettings/DatabaseSettingsWidget.h" +#include "remotesync/RemoteSyncProvider.h" + +#include +#include +#include + +class RemoteSettings; + +namespace Ui +{ + class DatabaseSettingsWidgetCloudSync; +} + +/// Provider-agnostic parent widget for cloud-sync settings. Holds a list of +/// registered CloudSyncPage subclasses produced by +/// CloudSyncPage::createBuiltinPages. Drives UI dispatch via the abstract +/// CloudSyncPage contract -- never via dynamic_cast or concrete-type +/// knowledge. +class DatabaseSettingsWidgetCloudSync : public DatabaseSettingsWidget +{ + Q_OBJECT + +public: + explicit DatabaseSettingsWidgetCloudSync(QWidget* parent = nullptr); + Q_DISABLE_COPY(DatabaseSettingsWidgetCloudSync); + ~DatabaseSettingsWidgetCloudSync() override; + +signals: + void cloudSyncTriggered(); + void settingsModified(); + +public slots: + void initialize() override; + void uninitialize() override; + bool saveSettings() override; + +private slots: + void onProviderChanged(int index); + void onPageShowMessage(const QString& text, int messageType, bool disableAutoHide); + void onPageHideMessage(); + void onPageRequestSync(); + +private: + bool hasScriptSyncConfig() const; + CloudSyncPage* activePage() const; + void registerPage(CloudSyncPage* page); + void updateSize(); + + QScopedPointer m_remoteSettings; + const QScopedPointer m_ui; + QList m_pages; // Insertion-ordered; index parallels providerComboBox. + bool m_lockedByScriptSync{false}; // Set by initialize() when Script Sync is configured -- gates save. +}; + +#endif // KEEPASSX_DATABASESETTINGSWIDGETCLOUDSYNC_H diff --git a/src/gui/remote/DatabaseSettingsWidgetCloudSync.ui b/src/gui/remote/DatabaseSettingsWidgetCloudSync.ui new file mode 100644 index 0000000000..6aedc59588 --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetCloudSync.ui @@ -0,0 +1,108 @@ + + + DatabaseSettingsWidgetCloudSync + + + + 0 + 0 + 652 + 400 + + + + + 0 + 0 + + + + + 450 + 0 + + + + + QLayout::SetMinimumSize + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Cloud Sync Configuration + + + + + + Provider: + + + + + + + + + + -1 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + MessageWidget + QWidget +
gui/MessageWidget.h
+ 1 +
+
+ + +
diff --git a/src/gui/remote/DatabaseSettingsWidgetRemote.cpp b/src/gui/remote/DatabaseSettingsWidgetRemote.cpp index 27b96909ea..000e7fdb08 100644 --- a/src/gui/remote/DatabaseSettingsWidgetRemote.cpp +++ b/src/gui/remote/DatabaseSettingsWidgetRemote.cpp @@ -67,6 +67,30 @@ void DatabaseSettingsWidgetRemote::initialize() } else { m_ui->removeSettingsButton->setDisabled(true); } + + // Reciprocal mutual-exclusivity gate. The Cloud Sync tab already locks + // itself when Script Sync is configured; without this side, a user could + // open the dialog with Cloud Sync already active and add a Script Sync + // entry on top -- both would land in CustomData and the save order + // (Remote then Cloud) makes recovery non-obvious. Lock save here too so + // the dialog enforces "one or the other, not both" symmetrically. + m_lockedByCloudSync = hasCloudSyncConfig(); + if (m_lockedByCloudSync) { + m_ui->messageWidget->showMessage( + tr("Cloud Sync is configured for this database. Remove it in the Cloud Sync tab before adding " + "a Script Sync entry."), + MessageWidget::Warning, + MessageWidget::DisableAutoHide); + m_ui->messageWidget->setCloseButtonVisible(false); + m_ui->saveSettingsButton->setEnabled(false); + } else { + m_ui->saveSettingsButton->setEnabled(true); + } +} + +bool DatabaseSettingsWidgetRemote::hasCloudSyncConfig() const +{ + return !m_remoteSettings->activeProvider().isEmpty(); } void DatabaseSettingsWidgetRemote::uninitialize() @@ -75,6 +99,15 @@ void DatabaseSettingsWidgetRemote::uninitialize() bool DatabaseSettingsWidgetRemote::saveSettings() { + // Reciprocal gate: when Cloud Sync owns this database, the Script Sync + // tab is read-only. Skip both the unsaved-changes prompt (the user + // couldn't have made changes -- the save button was disabled) and the + // saveSettings round-trip (which would no-op write the same data back, + // but is wasteful and could surprise reviewers). + if (m_lockedByCloudSync) { + return true; + } + if (m_modified) { auto ans = MessageBox::question(this, tr("Save Remote Settings"), @@ -100,16 +133,16 @@ void DatabaseSettingsWidgetRemote::saveCurrentSettings() return; } - auto* params = new RemoteParams(); - params->name = m_ui->nameLineEdit->text(); - params->downloadCommand = m_ui->downloadCommand->text(); - params->downloadInput = m_ui->inputForDownload->toPlainText(); - params->downloadTimeoutMsec = m_ui->downloadTimeoutSec->value() * 1000; - params->uploadCommand = m_ui->uploadCommand->text(); - params->uploadInput = m_ui->inputForUpload->toPlainText(); - params->uploadTimeoutMsec = m_ui->uploadTimeoutSec->value() * 1000; + RemoteParams params; + params.name = m_ui->nameLineEdit->text(); + params.downloadCommand = m_ui->downloadCommand->text(); + params.downloadInput = m_ui->inputForDownload->toPlainText(); + params.downloadTimeoutMsec = m_ui->downloadTimeoutSec->value() * 1000; + params.uploadCommand = m_ui->uploadCommand->text(); + params.uploadInput = m_ui->inputForUpload->toPlainText(); + params.uploadTimeoutMsec = m_ui->uploadTimeoutSec->value() * 1000; - m_remoteSettings->addRemoteParams(params); + m_remoteSettings->addRemoteParams(std::move(params)); updateSettingsList(); auto item = findItemByName(name); diff --git a/src/gui/remote/DatabaseSettingsWidgetRemote.h b/src/gui/remote/DatabaseSettingsWidgetRemote.h index 6184e4bd91..35a34a895e 100644 --- a/src/gui/remote/DatabaseSettingsWidgetRemote.h +++ b/src/gui/remote/DatabaseSettingsWidgetRemote.h @@ -55,10 +55,12 @@ private slots: void updateSettingsList(); QListWidgetItem* findItemByName(const QString& name); void clearFields(); + bool hasCloudSyncConfig() const; QScopedPointer m_remoteSettings; const QScopedPointer m_ui; bool m_modified = false; + bool m_lockedByCloudSync = false; // Set by initialize() when Cloud Sync is configured -- gates save. }; #endif // KEEPASSX_DATABASESETTINGSWIDGETREMOTE_H diff --git a/src/gui/remote/RemoteHandler.cpp b/src/gui/remote/RemoteHandler.cpp index c3bb857dd4..d263dce3be 100644 --- a/src/gui/remote/RemoteHandler.cpp +++ b/src/gui/remote/RemoteHandler.cpp @@ -70,7 +70,6 @@ RemoteHandler::RemoteResult RemoteHandler::download(const RemoteParams* params) bool finished = remoteProcess->waitForFinished(params->downloadTimeoutMsec); int statusCode = remoteProcess->exitCode(); - // TODO: For future use result.stdOutput = remoteProcess->readOutput(); result.stdError = remoteProcess->readError(); @@ -121,7 +120,6 @@ RemoteHandler::RemoteResult RemoteHandler::upload(const QString& filePath, const bool finished = remoteProcess->waitForFinished(params->uploadTimeoutMsec); int statusCode = remoteProcess->exitCode(); - // TODO: For future use result.stdOutput = remoteProcess->readOutput(); result.stdError = remoteProcess->readError(); diff --git a/src/gui/remote/RemoteHandler.h b/src/gui/remote/RemoteHandler.h index a46ee8c19f..7a574c5a48 100644 --- a/src/gui/remote/RemoteHandler.h +++ b/src/gui/remote/RemoteHandler.h @@ -32,6 +32,25 @@ class RemoteHandler : public QObject explicit RemoteHandler(QObject* parent = nullptr); ~RemoteHandler() override = default; + /// Provider-emitted error classification. Carrying the kind on the result + /// object lets retry/dispatch logic decide based on a machine-readable + /// signal instead of substring-matching tr()'d user-facing strings, which + /// silently breaks under translation. + enum class ErrorKind + { + Other, + AuthExpired, + AuthRevoked, + Network, + RateLimit, ///< 429 Too Many Requests; transient 423 Locked. + Conflict, ///< Concurrent modification (412 Precondition Failed / rev mismatch). + NotFound, + Quota, ///< 507 Insufficient Storage. + ServerError, ///< Generic 5xx (provider does not distinguish further). + Permission, ///< 403 when not auth-revoked (e.g. read-only share). + Aborted ///< User-initiated cancel (not a server error). + }; + struct RemoteResult { bool success; @@ -39,6 +58,9 @@ class RemoteHandler : public QObject QString filePath; QString stdOutput; QString stdError; + /// Provider-set classification. errorMessage is for the user + /// (localized); this field is for control flow. + ErrorKind kind = ErrorKind::Other; }; RemoteResult download(const RemoteParams* params); diff --git a/src/gui/remote/RemoteSettings.cpp b/src/gui/remote/RemoteSettings.cpp index f5af3b1e19..6aac964434 100644 --- a/src/gui/remote/RemoteSettings.cpp +++ b/src/gui/remote/RemoteSettings.cpp @@ -19,11 +19,13 @@ #include "core/Database.h" #include "core/Metadata.h" +#include "remotesync/RemoteSyncProvider.h" #include #include #include #include +#include RemoteSettings::RemoteSettings(const QSharedPointer& db, QObject* parent) : QObject(parent) @@ -31,22 +33,23 @@ RemoteSettings::RemoteSettings(const QSharedPointer& db, QObject* pare setDatabase(db); } -RemoteSettings::~RemoteSettings() = default; - void RemoteSettings::setDatabase(const QSharedPointer& db) { m_remoteParams.clear(); + m_providerConfigs.clear(); + m_activeProvider.clear(); + m_activeProviderTouched = false; m_db = db; loadSettings(); } -void RemoteSettings::addRemoteParams(RemoteParams* params) +void RemoteSettings::addRemoteParams(RemoteParams params) { - if (params->name.isEmpty()) { + if (params.name.isEmpty()) { qWarning() << "RemoteSettings::addRemoteParams: Remote parameters name is empty"; return; } - m_remoteParams.insert(params->name, params); + m_remoteParams.insert(params.name, std::move(params)); } void RemoteSettings::removeRemoteParams(const QString& name) @@ -56,15 +59,23 @@ void RemoteSettings::removeRemoteParams(const QString& name) RemoteParams* RemoteSettings::getRemoteParams(const QString& name) const { - if (m_remoteParams.contains(name)) { - return m_remoteParams.value(name); + auto it = m_remoteParams.constFind(name); + if (it == m_remoteParams.constEnd()) { + return nullptr; } - return nullptr; + // Logical-const accessor; callers expect a mutable pointer. + return const_cast(&it.value()); } QList RemoteSettings::getAllRemoteParams() const { - return m_remoteParams.values(); + QList result; + result.reserve(m_remoteParams.size()); + for (auto it = m_remoteParams.constBegin(); it != m_remoteParams.constEnd(); ++it) { + // See getRemoteParams above for the const_cast rationale. + result.append(const_cast(&it.value())); + } + return result; } void RemoteSettings::loadSettings() @@ -77,44 +88,184 @@ void RemoteSettings::loadSettings() void RemoteSettings::saveSettings() const { if (m_db) { - m_db->metadata()->customData()->set(CustomData::RemoteProgramSettings, toConfig()); + const QString out = toConfig(); + m_db->metadata()->customData()->set(CustomData::RemoteProgramSettings, out); + } +} + +bool RemoteSettings::hasAnySync() const +{ + if (!m_remoteParams.isEmpty()) { + return true; + } + if (!m_activeProvider.isEmpty()) { + return true; + } + // Defensive: even with no explicit active provider, an authorized + // provider config means sync is effectively configured -- fromConfig's + // lazy default would adopt it on the next load, so a change-key here + // still needs the snapshot. + for (const auto& cfg : m_providerConfigs) { + const QString type = cfg.value(QStringLiteral("type")).toString(); + QScopedPointer provider(RemoteSyncProvider::create(type, nullptr)); + if (provider && provider->isAuthorized(cfg)) { + return true; + } } + return false; } QString RemoteSettings::toConfig() const { - QJsonArray config; - for (const auto params : m_remoteParams.values()) { + QJsonArray providers; + for (auto it = m_remoteParams.constBegin(); it != m_remoteParams.constEnd(); ++it) { + const RemoteParams& params = it.value(); QJsonObject object; - object["name"] = params->name; - object["downloadCommand"] = params->downloadCommand; - object["downloadCommandInput"] = params->downloadInput; - object["downloadTimeoutMsec"] = params->downloadTimeoutMsec; - object["uploadCommand"] = params->uploadCommand; - object["uploadCommandInput"] = params->uploadInput; - object["uploadTimeoutMsec"] = params->uploadTimeoutMsec; - config << object; + object[QStringLiteral("type")] = QStringLiteral("command"); + object[QStringLiteral("name")] = params.name; + object[QStringLiteral("downloadCommand")] = params.downloadCommand; + object[QStringLiteral("downloadCommandInput")] = params.downloadInput; + object[QStringLiteral("downloadTimeoutMsec")] = params.downloadTimeoutMsec; + object[QStringLiteral("uploadCommand")] = params.uploadCommand; + object[QStringLiteral("uploadCommandInput")] = params.uploadInput; + object[QStringLiteral("uploadTimeoutMsec")] = params.uploadTimeoutMsec; + providers << object; + } + for (const auto& providerConfig : m_providerConfigs) { + providers << providerConfig; + } + + // If nothing in this session touched cloud-sync state, emit the + // raw-array shape so databases that never engage with cloud sync + // round-trip to byte-identical output. + if (!m_activeProviderTouched) { + return QString::fromUtf8(QJsonDocument(providers).toJson(QJsonDocument::Compact)); } - QJsonDocument doc(config); - return doc.toJson(QJsonDocument::Compact); + + QJsonObject wrapper; + wrapper[QStringLiteral("activeProvider")] = m_activeProvider; + wrapper[QStringLiteral("providers")] = providers; + return QString::fromUtf8(QJsonDocument(wrapper).toJson(QJsonDocument::Compact)); } void RemoteSettings::fromConfig(const QString& data) { m_remoteParams.clear(); + m_providerConfigs.clear(); + m_activeProvider.clear(); + m_activeProviderTouched = false; QJsonDocument json = QJsonDocument::fromJson(data.toUtf8()); - for (const auto& item : json.array().toVariantList()) { - auto itemMap = item.toMap(); - auto* params = new RemoteParams(); - params->name = itemMap["name"].toString(); - params->downloadCommand = itemMap["downloadCommand"].toString(); - params->downloadInput = itemMap["downloadCommandInput"].toString(); - params->downloadTimeoutMsec = itemMap.value("downloadTimeoutMsec", 10000).toInt(); - params->uploadCommand = itemMap["uploadCommand"].toString(); - params->uploadInput = itemMap["uploadCommandInput"].toString(); - params->uploadTimeoutMsec = itemMap.value("uploadTimeoutMsec", 10000).toInt(); - - m_remoteParams.insert(params->name, params); + + QJsonArray providers; + bool wrappedShape = false; + + if (json.isArray()) { + // Raw-array shape -- the entire document is the providers list. + providers = json.array(); + } else if (json.isObject()) { + // Wrapped-object shape -- {"activeProvider":"...","providers":[...]}. + wrappedShape = true; + QJsonObject wrapper = json.object(); + m_activeProvider = wrapper.value(QStringLiteral("activeProvider")).toString(); + providers = wrapper.value(QStringLiteral("providers")).toArray(); + } + + for (const auto& item : providers) { + QJsonObject obj = item.toObject(); + + // Read type field, default to "command" for backward compatibility. + QString type = obj.value(QStringLiteral("type")).toString(QStringLiteral("command")); + + if (type == QStringLiteral("command") || type.isEmpty()) { + auto itemMap = item.toVariant().toMap(); + RemoteParams params; + params.name = itemMap[QStringLiteral("name")].toString(); + params.downloadCommand = itemMap[QStringLiteral("downloadCommand")].toString(); + params.downloadInput = itemMap[QStringLiteral("downloadCommandInput")].toString(); + params.downloadTimeoutMsec = itemMap.value(QStringLiteral("downloadTimeoutMsec"), 10000).toInt(); + params.uploadCommand = itemMap[QStringLiteral("uploadCommand")].toString(); + params.uploadInput = itemMap[QStringLiteral("uploadCommandInput")].toString(); + params.uploadTimeoutMsec = itemMap.value(QStringLiteral("uploadTimeoutMsec"), 10000).toInt(); + + m_remoteParams.insert(params.name, std::move(params)); + } else { + // Generic provider config (every non-command type) stored verbatim + // so an older binary loading a future provider's entries round-trips + // them unchanged. + m_providerConfigs.append(obj); + } + } + + // Raw-array shape has no activeProvider field; adopt the first authorized + // config as a lazy default. Inferred values do not trip + // m_activeProviderTouched so the round-trip stays in raw-array shape. + if (!wrappedShape) { + for (const auto& cfg : m_providerConfigs) { + const QString type = cfg.value(QStringLiteral("type")).toString(); + QScopedPointer provider(RemoteSyncProvider::create(type, nullptr)); + if (provider && provider->isAuthorized(cfg)) { + m_activeProvider = type; + break; + } + } + } +} + +QJsonObject RemoteSettings::getProviderConfig(const QString& type, const QString& name) const +{ + for (const auto& cfg : m_providerConfigs) { + if (cfg.value(QStringLiteral("type")).toString() == type + && cfg.value(QStringLiteral("name")).toString() == name) { + return cfg; + } + } + return QJsonObject{}; +} + +void RemoteSettings::setProviderConfig(const QString& type, const QString& name, const QJsonObject& config) +{ + Q_ASSERT(!type.isEmpty() && !name.isEmpty()); + + for (int i = 0; i < m_providerConfigs.size(); ++i) { + const auto& existing = m_providerConfigs.at(i); + if (existing.value(QStringLiteral("type")).toString() == type + && existing.value(QStringLiteral("name")).toString() == name) { + m_providerConfigs[i] = config; + m_activeProviderTouched = true; + return; + } } + m_providerConfigs.append(config); + m_activeProviderTouched = true; +} + +void RemoteSettings::removeProviderConfig(const QString& type, const QString& name) +{ + for (int i = 0; i < m_providerConfigs.size(); ++i) { + const auto& existing = m_providerConfigs.at(i); + if (existing.value(QStringLiteral("type")).toString() == type + && existing.value(QStringLiteral("name")).toString() == name) { + m_providerConfigs.removeAt(i); + // Clear active if it pointed at the removed entry; downstream consumers + // (isCloudSyncAuthorized, auto-select) expect activeProvider to name a + // real config. + if (m_activeProvider == type) { + m_activeProvider.clear(); + } + m_activeProviderTouched = true; + return; + } + } +} + +QString RemoteSettings::activeProvider() const +{ + return m_activeProvider; +} + +void RemoteSettings::setActiveProvider(const QString& type) +{ + m_activeProvider = type; + m_activeProviderTouched = true; } diff --git a/src/gui/remote/RemoteSettings.h b/src/gui/remote/RemoteSettings.h index c1f61fc776..f7c6d69a2d 100644 --- a/src/gui/remote/RemoteSettings.h +++ b/src/gui/remote/RemoteSettings.h @@ -19,8 +19,11 @@ #define KEEPASSXC_REMOTESETTINGS_H #include +#include +#include #include #include +#include class Database; @@ -41,23 +44,59 @@ class RemoteSettings : public QObject Q_OBJECT public: explicit RemoteSettings(const QSharedPointer& db, QObject* parent = nullptr); - ~RemoteSettings() override; + ~RemoteSettings() override = default; void setDatabase(const QSharedPointer& db); - void addRemoteParams(RemoteParams* params); + void addRemoteParams(RemoteParams params); void removeRemoteParams(const QString& name); + // Returns a non-owning pointer into m_remoteParams. The pointer is stable while + // m_remoteParams is not mutated; addRemoteParams may rehash and invalidate it + // (Qt 6 QHash is open-addressed). const_cast in the impl exposes a mutable + // pointer from a const accessor to keep existing call sites unchanged. RemoteParams* getRemoteParams(const QString& name) const; QList getAllRemoteParams() const; + // Generic provider-config API. Lookup is linear over m_providerConfigs; + // (type, name) is the unique key. + + /// Get the persisted JSON config for (type, name). Returns an empty + /// object if no entry exists. + QJsonObject getProviderConfig(const QString& type, const QString& name) const; + /// Persist the JSON config under (type, name); replaces any existing entry. + void setProviderConfig(const QString& type, const QString& name, const QJsonObject& config); + /// Remove the persisted entry under (type, name); no-op if not present. + void removeProviderConfig(const QString& type, const QString& name); + + // Active-provider accessors. Mutating setter trips the touched flag so + // toConfig switches from raw-array to wrapped-object shape. + + /// Returns the type-tag of the currently active provider (e.g. "dropbox"). + /// When not explicitly set, defaults lazily to the first persisted entry + /// for which the provider reports isAuthorized(). + QString activeProvider() const; + /// Set the active provider type-tag. Trips the touched flag so subsequent + /// saves use the wrapped-object on-disk shape. + void setActiveProvider(const QString& type); + void loadSettings(); void saveSettings() const; + /// True iff this database has any sync configured: a Script Sync entry + /// in m_remoteParams, OR an active Cloud Sync provider, OR any provider + /// config that represents an authorized state. Used to gate the + /// change-key syncPreviousKey snapshot -- there is no reason to retain + /// the old composite key in memory for users who don't sync. + bool hasAnySync() const; + private: void fromConfig(const QString& data); QString toConfig() const; - QHash m_remoteParams; + QHash m_remoteParams; + QList m_providerConfigs; // Insertion-ordered; preserves disk order. + QString m_activeProvider; // Empty until setActiveProvider or lazy-default. + bool m_activeProviderTouched = false; // When false, toConfig emits raw-array shape; when true, wrapped-object. QSharedPointer m_db; }; diff --git a/src/gui/remote/dropbox/DropboxCloudSyncPage.cpp b/src/gui/remote/dropbox/DropboxCloudSyncPage.cpp new file mode 100644 index 0000000000..7fadedfb1c --- /dev/null +++ b/src/gui/remote/dropbox/DropboxCloudSyncPage.cpp @@ -0,0 +1,671 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DropboxCloudSyncPage.h" +#include "ui_DropboxCloudSyncPage.h" + +#include "gui/MessageWidget.h" +#include "gui/remote/RemoteSettings.h" +#include "gui/styles/StateColorPalette.h" +#include "remotesync/DropboxLoginFlow.h" +#include "remotesync/DropboxSyncProvider.h" +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include + +const QString DropboxCloudSyncPage::ConfigName = QStringLiteral("dropbox-default"); + +DropboxCloudSyncPage::DropboxCloudSyncPage(QWidget* parent) + : CloudSyncPage(parent) + , m_ui(new Ui::DropboxCloudSyncPage()) +{ + m_ui->setupUi(this); + m_ui->manualCodeWidget->setHidden(true); + + // Click handlers ---------------------------------------------------- + connect(m_ui->authorizeButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onAuthorizeClicked); + connect(m_ui->testConnectionButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onTestConnectionClicked); + connect(m_ui->removeButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onRemoveClicked); + connect(m_ui->submitCodeButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onSubmitManualCode); + connect(m_ui->cancelCodeButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onCancelManualCode); + connect(m_ui->triggerSyncButton, &QPushButton::clicked, this, &DropboxCloudSyncPage::onTriggerSyncClicked); + + // Modified signal wiring ------------------------------------------- + // Disabling triggerSyncButton when m_modified flips true ensures + // Sync Now obeys the "click Apply first" banner -- syncing reads + // from RemoteSettings (persisted), not page state, so an unsaved + // edit would either no-op or fire stale credentials. + auto markModified = [this] { + m_modified = true; + m_ui->triggerSyncButton->setEnabled(false); + emit modified(); + }; + connect(m_ui->appKeyEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->remotePathEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->syncOnSaveCheckBox, &QCheckBox::toggled, this, markModified); + connect(m_ui->syncOnOpenCheckBox, &QCheckBox::toggled, this, markModified); +} + +DropboxCloudSyncPage::~DropboxCloudSyncPage() = default; + +// --------------------------------------------------------------------------- +// CloudSyncPage contract overrides +// --------------------------------------------------------------------------- + +void DropboxCloudSyncPage::setProvider(RemoteSyncProvider* provider) +{ + // Borrowed pointer; cast to concrete type for the Dropbox-specific + // revokeToken method (not on the abstract base since revocation is an + // OAuth-specific operation). qobject_cast inside a Dropbox*-named file is + // the legitimate exemption. The provider's lifetime is owned by the + // widget that constructed it via RemoteSyncProvider::create. + m_dropboxProvider = qobject_cast(provider); +} + +QString DropboxCloudSyncPage::providerType() const +{ + return QStringLiteral("dropbox"); +} + +QString DropboxCloudSyncPage::providerDisplayName() const +{ + return QStringLiteral("Dropbox"); +} + +void DropboxCloudSyncPage::loadFromConfig(const QJsonObject& config) +{ + // Reset UI to clean state before loading (handles re-entry). + m_ui->manualCodeWidget->setHidden(true); + m_ui->manualCodeEdit->clear(); + m_ui->authorizeButton->setHidden(false); + setFieldsEnabled(true); + + m_config = config; + + // Block signals while populating UI from config to avoid false modified-flag triggers + const QSignalBlocker appKeyBlocker(m_ui->appKeyEdit); + const QSignalBlocker remotePathBlocker(m_ui->remotePathEdit); + const QSignalBlocker syncOnSaveBlocker(m_ui->syncOnSaveCheckBox); + const QSignalBlocker syncOnOpenBlocker(m_ui->syncOnOpenCheckBox); + + m_ui->appKeyEdit->setText(m_config[QStringLiteral("appKey")].toString()); + m_ui->remotePathEdit->setText(m_config[QStringLiteral("remotePath")].toString()); + m_ui->syncOnSaveCheckBox->setChecked(m_config.value(QStringLiteral("syncOnSave")).toBool(true)); + m_ui->syncOnOpenCheckBox->setChecked(m_config.value(QStringLiteral("syncOnOpen")).toBool(true)); + + QString accessToken = m_config[QStringLiteral("accessToken")].toString(); + if (!accessToken.isEmpty()) { + m_authState = AuthState::Authorized; + } else { + m_authState = AuthState::Idle; + } + // Reset m_modified BEFORE updateAuthStatus so the Authorized branch's + // triggerSyncButton gate sees the post-load state (clean, not dirty). + m_modified = false; + updateAuthStatus(m_authState); +} + +QJsonObject DropboxCloudSyncPage::saveToConfig() const +{ + // Fresh-no-edit fast path: every field empty AND no cached config means + // the user opened the dialog and never touched anything. Return an empty + // object so the parent skips persistence entirely. + if (m_ui->appKeyEdit->text().trimmed().isEmpty() && m_ui->remotePathEdit->text().trimmed().isEmpty() + && m_config.isEmpty()) { + return QJsonObject(); + } + + QJsonObject config; + config[QStringLiteral("type")] = providerType(); + config[QStringLiteral("name")] = ConfigName; + config[QStringLiteral("appKey")] = m_ui->appKeyEdit->text().trimmed(); + config[QStringLiteral("remotePath")] = m_ui->remotePathEdit->text().trimmed(); + + // Preserve token fields from cached config (auth flow sets these, not the UI) + if (m_config.contains(QStringLiteral("accessToken"))) { + config[QStringLiteral("accessToken")] = m_config[QStringLiteral("accessToken")]; + } + if (m_config.contains(QStringLiteral("refreshToken"))) { + config[QStringLiteral("refreshToken")] = m_config[QStringLiteral("refreshToken")]; + } + if (m_config.contains(QStringLiteral("expiresAt"))) { + config[QStringLiteral("expiresAt")] = m_config[QStringLiteral("expiresAt")]; + } + + config[QStringLiteral("syncOnSave")] = m_ui->syncOnSaveCheckBox->isChecked(); + config[QStringLiteral("syncOnOpen")] = m_ui->syncOnOpenCheckBox->isChecked(); + return config; +} + +bool DropboxCloudSyncPage::isModified() const +{ + return m_modified; +} + +// --------------------------------------------------------------------------- +// Dropbox-specific orchestration +// --------------------------------------------------------------------------- + +std::unique_ptr DropboxCloudSyncPage::buildDropboxParams() const +{ + auto params = std::make_unique(); + params->type = providerType(); + params->name = ConfigName; + // UI fields override config values (user may have edited them) + params->appKey = m_ui->appKeyEdit->text().trimmed(); + params->remotePath = m_ui->remotePathEdit->text().trimmed(); + // Token data comes from stored config + params->accessToken = m_config[QStringLiteral("accessToken")].toString(); + params->refreshToken = m_config[QStringLiteral("refreshToken")].toString(); + params->expiresAt = QDateTime::fromMSecsSinceEpoch(m_config[QStringLiteral("expiresAt")].toVariant().toLongLong()); + params->timeoutMsec = 30000; + return params; +} + +void DropboxCloudSyncPage::setRemoteSettings(RemoteSettings* settings) +{ + m_remoteSettings = settings; +} + +void DropboxCloudSyncPage::setMutualExclusivityWarning(bool active) +{ + m_mutualExclusivityActive = active; + setFieldsEnabled(!active); +} + +void DropboxCloudSyncPage::setFieldsEnabled(bool enabled) +{ + m_ui->appKeyEdit->setEnabled(enabled); + m_ui->remotePathEdit->setEnabled(enabled); + m_ui->authorizeButton->setEnabled(enabled); + m_ui->testConnectionButton->setEnabled(enabled); + m_ui->removeButton->setEnabled(enabled); + m_ui->syncOnSaveCheckBox->setEnabled(enabled); + m_ui->syncOnOpenCheckBox->setEnabled(enabled); +} + +// --------------------------------------------------------------------------- +// Click handlers +// --------------------------------------------------------------------------- + +void DropboxCloudSyncPage::onAuthorizeClicked() +{ + // In Authorized state, the button acts as "Revoke". + if (m_authState == AuthState::Authorized) { + onRevokeClicked(); + return; + } + + // In Authorizing state, the button acts as "Cancel Authorization". + // DropboxLoginFlow::cancel is idempotent and emits authorizationCancelled + // exactly once when it stops an active flow -- the slot resets our state. + if (m_authState == AuthState::Authorizing) { + if (m_loginFlow) { + m_loginFlow->cancel(); + } + return; + } + + // Validate: app key required. + const QString appKey = m_ui->appKeyEdit->text().trimmed(); + if (appKey.isEmpty()) { + emit showMessage(tr("App Key is required for authorization."), MessageWidget::Warning, false); + return; + } + + // Lazy-construct the login flow on first Authorize click; reuse on + // subsequent clicks (startAuthorization is internally cancel-previous, + // so reuse has equivalent semantics with less destructor churn). + ensureLoginFlow(); + + // Enter Authorizing state (changes button to "Cancel Authorization", + // disables fields). Keep the Authorize button live so its "Cancel + // Authorization" label is clickable. + updateAuthStatus(AuthState::Authorizing); + setFieldsEnabled(false); + m_ui->authorizeButton->setEnabled(true); + + // Start the flow. Signal-driven from here -- no nested QEventLoop, no + // QPointer reentrancy guard needed. The 4 slot wirings in + // ensureLoginFlow handle every terminal outcome. + m_loginFlow->startAuthorization(appKey, 30000); + + emit requestAuthorize(); +} + +void DropboxCloudSyncPage::onRevokeClicked() +{ + if (!m_dropboxProvider) { + return; + } + + // Build params from current config for revocation + auto params = buildDropboxParams(); + + // Show revoking feedback + updateAuthStatus(AuthState::Revoking); + setFieldsEnabled(false); + + // Best-effort revoke -- ignore result. + QPointer guard(this); + m_dropboxProvider->revokeToken(params.get()); + if (!guard) { + return; + } + + // Clear token fields from cached config + m_config.remove(QStringLiteral("accessToken")); + m_config.remove(QStringLiteral("refreshToken")); + m_config.remove(QStringLiteral("expiresAt")); + + // Dialog may have been torn down during the nested event loop in revokeToken(). + if (!m_remoteSettings) { + return; + } + // Persist cleared config + m_remoteSettings->setProviderConfig(providerType(), ConfigName, m_config); + m_remoteSettings->saveSettings(); + + // Restore idle state + updateAuthStatus(AuthState::Idle); + setFieldsEnabled(true); + + emit showMessage(tr("Token revoked."), MessageWidget::Positive, false); +} + +void DropboxCloudSyncPage::onTestConnectionClicked() +{ + // Validate prerequisites + if (m_config[QStringLiteral("accessToken")].toString().isEmpty()) { + emit showMessage(tr("Authorize first before testing the connection."), MessageWidget::Warning, false); + return; + } + if (m_ui->remotePathEdit->text().trimmed().isEmpty()) { + emit showMessage(tr("Remote path is required."), MessageWidget::Warning, false); + return; + } + + if (!m_dropboxProvider) { + return; + } + + auto params = buildDropboxParams(); + + // Guard against widget destruction during nested event loops below. + QPointer guard(this); + + // Refresh auth first (no-op if access token still valid). Mirrors + // SyncEngine::doAuthenticate so a stale access token doesn't fail the test + // when the refresh token is still valid. + auto refreshResult = m_dropboxProvider->refreshAuth(params.get()); + if (!guard) { + return; + } + if (!refreshResult.success) { + emit showMessage(refreshResult.errorMessage, MessageWidget::Error, false); + return; + } + if (!refreshResult.stdOutput.isEmpty()) { + QJsonDocument doc = QJsonDocument::fromJson(refreshResult.stdOutput.toUtf8()); + if (!doc.isNull() && doc.isObject()) { + QJsonObject tokenData = doc.object(); + if (tokenData.contains(QStringLiteral("accessToken"))) { + params->accessToken = tokenData[QStringLiteral("accessToken")].toString(); + m_config[QStringLiteral("accessToken")] = params->accessToken; + } + if (tokenData.contains(QStringLiteral("expiresAt"))) { + params->expiresAt = + QDateTime::fromMSecsSinceEpoch(tokenData[QStringLiteral("expiresAt")].toVariant().toLongLong()); + m_config[QStringLiteral("expiresAt")] = tokenData[QStringLiteral("expiresAt")]; + } + // Dialog may have been torn down during the nested event loop in refreshAuth(). + if (!m_remoteSettings) { + return; + } + m_remoteSettings->setProviderConfig(providerType(), ConfigName, m_config); + } + } + + // Attempt download (blocks via internal QEventLoop). + // m_dropboxProvider may have been auto-nulled during refreshAuth()'s nested event loop. + if (!m_dropboxProvider) { + return; + } + RemoteHandler::RemoteResult result = m_dropboxProvider->download(params.get()); + if (!guard) { + return; + } + + if (result.success) { + if (!result.filePath.isEmpty()) { + // Clean up temp file immediately + QFile::remove(result.filePath); + emit showMessage(tr("Connected. Remote file found."), MessageWidget::Positive, false); + } else { + emit showMessage( + tr("Connected. File not found -- it will be created on first sync."), MessageWidget::Positive, false); + } + } else { + // Prefer the kind set by the provider at the source. Falling back to + // classifyError(errorMsg) parses raw OAuth strings (which work) plus + // tr()'d wrapper strings (which silently miss on localized builds). + QString errorMsg = result.errorMessage; + auto kind = result.kind; + // Dialog may have been torn down during the nested event loop in download(). + if (kind == RemoteSyncProvider::ErrorKind::Other && m_dropboxProvider) { + kind = m_dropboxProvider->classifyError(errorMsg); + } + // Wording shared verbatim with NextcloudCloudSyncPage::onTestConnectionClicked + // so users see the same banner for the same condition across providers. + if (kind == RemoteSyncProvider::ErrorKind::AuthExpired || kind == RemoteSyncProvider::ErrorKind::AuthRevoked) { + errorMsg = tr("Authorization expired. Re-authorize."); + } else if (kind == RemoteSyncProvider::ErrorKind::Network) { + errorMsg = tr("Network error: timeout."); + } else if (kind == RemoteSyncProvider::ErrorKind::NotFound) { + errorMsg = tr("Remote path not found."); + } + emit showMessage(errorMsg, MessageWidget::Error, false); + } + + emit requestTestConnection(); +} + +void DropboxCloudSyncPage::onRemoveClicked() +{ + // If authorized, revoke tokens first (best-effort) + if (m_authState == AuthState::Authorized) { + if (!m_dropboxProvider) { + return; + } + auto params = buildDropboxParams(); + QPointer guard(this); + m_dropboxProvider->revokeToken(params.get()); + if (!guard) { + return; + } + } + + // Clear cached config entirely + m_config = QJsonObject(); + + // Dialog may have been torn down during the nested event loop in revokeToken(). + if (!m_remoteSettings) { + return; + } + // Remove from RemoteSettings persistence + m_remoteSettings->removeProviderConfig(providerType(), ConfigName); + m_remoteSettings->saveSettings(); + + // Clear UI fields under QSignalBlockers -- otherwise each clear() fires + // textChanged -> markModified -> emit modified(), which would leave Apply + // enabled-with-nothing-to-apply (saveToConfig would just return empty). + // Mirrors NextcloudCloudSyncPage::onRemoveClicked. + { + const QSignalBlocker appKeyBlocker(m_ui->appKeyEdit); + const QSignalBlocker remotePathBlocker(m_ui->remotePathEdit); + m_ui->appKeyEdit->clear(); + m_ui->remotePathEdit->clear(); + } + + // Restore idle state + updateAuthStatus(AuthState::Idle); + setFieldsEnabled(true); + + emit showMessage(tr("Cloud sync configuration removed."), MessageWidget::Positive, false); + + // After Remove, the page state matches the now-removed RemoteSettings entry; + // Apply should disable. saveSettings() above already persisted the removal. + m_modified = false; + + emit requestRemove(); +} + +void DropboxCloudSyncPage::onSubmitManualCode() +{ + const QString authCode = m_ui->manualCodeEdit->text().trimmed(); + if (authCode.isEmpty()) { + emit showMessage(tr("Please enter the authorization code."), MessageWidget::Warning, false); + return; + } + if (!m_loginFlow) { + // Defensive: should not happen -- the manual code widget only appears + // after onAuthorizationManualFallback fired, which means the flow exists. + emit showMessage(tr("Authorization is not in progress. Click Authorize first."), + MessageWidget::Warning, + false); + return; + } + + // Hand off to the flow; outcome arrives via onAuthorizationCompleted or + // onAuthorizationFailed. UI cleanup happens in those slots so the manual + // code widget stays visible while the exchange POST is in flight. + m_loginFlow->submitManualCode(authCode, 30000); +} + +void DropboxCloudSyncPage::onCancelManualCode() +{ + // Cancel the flow -- onAuthorizationCancelled will reset UI state. + if (m_loginFlow) { + m_loginFlow->cancel(); + } else { + // Defensive same-shape teardown if somehow the flow is null. + m_ui->manualCodeEdit->clear(); + m_ui->manualCodeWidget->setHidden(true); + m_ui->authorizeButton->setHidden(false); + updateAuthStatus(AuthState::Idle); + setFieldsEnabled(true); + } +} + +void DropboxCloudSyncPage::onTriggerSyncClicked() +{ + emit requestSync(); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +void DropboxCloudSyncPage::updateAuthStatus(AuthState state) +{ + m_authState = state; + + StateColorPalette statePalette; + const QString boldGreen = + QStringLiteral("font-weight: bold; color: %1;").arg(statePalette.color(StateColorPalette::True).name()); + const QString boldRed = + QStringLiteral("font-weight: bold; color: %1;").arg(statePalette.color(StateColorPalette::False).name()); + + switch (state) { + case AuthState::Idle: + m_ui->authStatusLabel->setText(tr("Not authorized")); + m_ui->authStatusLabel->setStyleSheet(boldRed); + m_ui->authorizeButton->setText(tr("Authorize")); + m_ui->manualCodeWidget->setHidden(true); + m_ui->triggerSyncButton->setEnabled(false); + break; + + case AuthState::Authorizing: + m_ui->authStatusLabel->setText(tr("Waiting for browser authorization...")); + m_ui->authStatusLabel->setStyleSheet(QString()); + m_ui->authorizeButton->setText(tr("Cancel Authorization")); + m_ui->manualCodeWidget->setHidden(true); + m_ui->triggerSyncButton->setEnabled(false); + break; + + case AuthState::ManualFallback: + m_ui->authStatusLabel->setText(tr("Enter authorization code from browser")); + m_ui->authStatusLabel->setStyleSheet(QString()); + m_ui->authorizeButton->setText(tr("Authorize")); + m_ui->manualCodeWidget->setHidden(false); + m_ui->triggerSyncButton->setEnabled(false); + break; + + case AuthState::Authorized: + m_ui->authStatusLabel->setText(tr("Authorized")); + m_ui->authStatusLabel->setStyleSheet(boldGreen); + m_ui->authorizeButton->setText(tr("Revoke")); + m_ui->manualCodeWidget->setHidden(true); + // Sync Now stays disabled while the page has unsaved edits or a + // just-completed auth that hasn't been Applied -- syncing reads + // persisted RemoteSettings, not page state. + m_ui->triggerSyncButton->setEnabled(!m_modified); + break; + + case AuthState::Revoking: + m_ui->authStatusLabel->setText(tr("Revoking...")); + m_ui->authStatusLabel->setStyleSheet(QString()); + m_ui->triggerSyncButton->setEnabled(false); + break; + } +} + +void DropboxCloudSyncPage::mergeAndPersistTokens(const QString& accessToken, + const QString& refreshToken, + qint64 expiresAtMs) +{ + // Ensure config always has required metadata (auth may fire before saveSettings). + m_config[QStringLiteral("type")] = providerType(); + m_config[QStringLiteral("name")] = ConfigName; + m_config[QStringLiteral("appKey")] = m_ui->appKeyEdit->text().trimmed(); + m_config[QStringLiteral("remotePath")] = m_ui->remotePathEdit->text().trimmed(); + + m_config[QStringLiteral("accessToken")] = accessToken; + m_config[QStringLiteral("refreshToken")] = refreshToken; + m_config[QStringLiteral("expiresAt")] = static_cast(expiresAtMs); + + if (m_remoteSettings) { + m_remoteSettings->setProviderConfig(providerType(), ConfigName, m_config); + } + + // Mark the page dirty so the dialog's Apply button enables -- otherwise a + // user who clicked Authorize without first editing the app key (key was + // already loaded from CustomData) sees "Authorized" with Apply disabled + // and loses tokens on dialog close. + m_modified = true; + emit modified(); + + // Don't save the database here -- the settings dialog Apply handles + // persistence. Saving here would trigger databaseSaved -> sync-on-save + // while the dialog is still open, causing a duplicate sync. +} + +void DropboxCloudSyncPage::ensureLoginFlow() +{ + if (m_loginFlow) { + return; + } + m_loginFlow.reset(new DropboxLoginFlow(this)); + connect(m_loginFlow.data(), + &DropboxLoginFlow::authorizationManualFallback, + this, + &DropboxCloudSyncPage::onAuthorizationManualFallback); + connect(m_loginFlow.data(), + &DropboxLoginFlow::authorizationCompleted, + this, + &DropboxCloudSyncPage::onAuthorizationCompleted); + connect(m_loginFlow.data(), + &DropboxLoginFlow::authorizationFailed, + this, + &DropboxCloudSyncPage::onAuthorizationFailed); + connect(m_loginFlow.data(), + &DropboxLoginFlow::authorizationCancelled, + this, + &DropboxCloudSyncPage::onAuthorizationCancelled); +} + +void DropboxCloudSyncPage::setLoginFlowForTest(DropboxLoginFlow* flow) +{ + // Test seam: caller hands off ownership; we reparent and wire signals so + // the page treats it like the lazily-constructed default. + m_loginFlow.reset(flow); + if (flow) { + flow->setParent(this); + connect(flow, + &DropboxLoginFlow::authorizationManualFallback, + this, + &DropboxCloudSyncPage::onAuthorizationManualFallback); + connect(flow, + &DropboxLoginFlow::authorizationCompleted, + this, + &DropboxCloudSyncPage::onAuthorizationCompleted); + connect(flow, + &DropboxLoginFlow::authorizationFailed, + this, + &DropboxCloudSyncPage::onAuthorizationFailed); + connect(flow, + &DropboxLoginFlow::authorizationCancelled, + this, + &DropboxCloudSyncPage::onAuthorizationCancelled); + } +} + +// --------------------------------------------------------------------------- +// DropboxLoginFlow signal-slot bodies +// --------------------------------------------------------------------------- + +void DropboxCloudSyncPage::onAuthorizationManualFallback(const QString& codeVerifier) +{ + Q_UNUSED(codeVerifier); // owned inside m_loginFlow; no page-side copy needed + updateAuthStatus(AuthState::ManualFallback); + setFieldsEnabled(true); + // Keep the top-level Authorize button hidden during manual fallback; the + // sub-panel's Submit / Cancel buttons drive the flow from here. + m_ui->authorizeButton->setHidden(true); + m_ui->manualCodeEdit->setFocus(); +} + +void DropboxCloudSyncPage::onAuthorizationCompleted(const QString& accessToken, + const QString& refreshToken, + qint64 expiresAtMs) +{ + mergeAndPersistTokens(accessToken, refreshToken, expiresAtMs); + updateAuthStatus(AuthState::Authorized); + setFieldsEnabled(true); + // Either path through onAuthorizationCompleted leaves the manual code widget + // hidden -- updateAuthStatus(Authorized) already hides it, but be explicit + // about the manual-fallback-then-success path too. + m_ui->manualCodeEdit->clear(); + m_ui->manualCodeWidget->setHidden(true); + m_ui->authorizeButton->setHidden(false); + + emit showMessage(tr("Authorization successful, click Apply to save."), MessageWidget::Positive, false); +} + +void DropboxCloudSyncPage::onAuthorizationFailed(const QString& reason) +{ + emit showMessage(reason, MessageWidget::Error, false); + updateAuthStatus(AuthState::Idle); + setFieldsEnabled(true); + m_ui->manualCodeEdit->clear(); + m_ui->manualCodeWidget->setHidden(true); + m_ui->authorizeButton->setHidden(false); +} + +void DropboxCloudSyncPage::onAuthorizationCancelled() +{ + // Cancellation is silent (the user clicked Cancel themselves; surfacing a + // banner would be redundant). Mirrors NextcloudCloudSyncPage::onLoginCancelled. + updateAuthStatus(AuthState::Idle); + setFieldsEnabled(true); + m_ui->manualCodeEdit->clear(); + m_ui->manualCodeWidget->setHidden(true); + m_ui->authorizeButton->setHidden(false); +} diff --git a/src/gui/remote/dropbox/DropboxCloudSyncPage.h b/src/gui/remote/dropbox/DropboxCloudSyncPage.h new file mode 100644 index 0000000000..5091463c6b --- /dev/null +++ b/src/gui/remote/dropbox/DropboxCloudSyncPage.h @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_DROPBOXCLOUDSYNCPAGE_H +#define KEEPASSX_DROPBOXCLOUDSYNCPAGE_H + +#include "gui/remote/CloudSyncPage.h" +#include "gui/remote/RemoteHandler.h" + +#include +#include +#include +#include + +class DropboxLoginFlow; +class DropboxSyncProvider; +class RemoteSettings; +struct DropboxSyncParams; + +namespace Ui +{ + class DropboxCloudSyncPage; +} + +class DropboxCloudSyncPage : public CloudSyncPage +{ + Q_OBJECT + +public: + explicit DropboxCloudSyncPage(QWidget* parent = nullptr); + ~DropboxCloudSyncPage() override; + + // CloudSyncPage contract ---------------------------------------------- + void setProvider(RemoteSyncProvider* provider) override; + QString providerType() const override; + QString providerDisplayName() const override; + void loadFromConfig(const QJsonObject& config) override; + QJsonObject saveToConfig() const override; + bool isModified() const override; + void setRemoteSettings(RemoteSettings* settings) override; + void setMutualExclusivityWarning(bool active) override; + + static const QString ConfigName; + + // Test seam: inject a DropboxLoginFlow (typically a MockDropboxLoginFlow) + // BEFORE the first Authorize click. Production lazy-constructs the default + // on first click via onAuthorizeClicked. Page takes ownership. + void setLoginFlowForTest(DropboxLoginFlow* flow); + +private slots: + void onAuthorizeClicked(); + void onTestConnectionClicked(); + void onRemoveClicked(); + void onRevokeClicked(); + void onSubmitManualCode(); + void onCancelManualCode(); + void onTriggerSyncClicked(); + + // 4 DropboxLoginFlow signals -- mirror NextcloudCloudSyncPage's slot set. + void onAuthorizationManualFallback(const QString& codeVerifier); + void onAuthorizationCompleted(const QString& accessToken, const QString& refreshToken, qint64 expiresAtMs); + void onAuthorizationFailed(const QString& reason); + void onAuthorizationCancelled(); + +private: + Q_DISABLE_COPY(DropboxCloudSyncPage) + + enum class AuthState + { + Idle, + Authorizing, + ManualFallback, + Authorized, + Revoking + }; + + void updateAuthStatus(AuthState state); + + // Persist freshly-acquired tokens into m_config + RemoteSettings (without + // saving the database; Apply-button click is the persist gate). + void mergeAndPersistTokens(const QString& accessToken, const QString& refreshToken, qint64 expiresAtMs); + + // Build a DropboxSyncParams from the current UI fields + cached config. + // Used by Revoke / Test Connection click handlers. + std::unique_ptr buildDropboxParams() const; + + // Disable / re-enable editable fields during in-flight auth or under the + // mutual-exclusivity warning. + void setFieldsEnabled(bool enabled); + + // Lazy-construct m_loginFlow + wire signals. Idempotent (no-op if already + // constructed). Mirrors NextcloudCloudSyncPage's lazy-construct pattern. + void ensureLoginFlow(); + + QScopedPointer m_ui; + QScopedPointer m_loginFlow; // lazily instantiated on first Authorize + QPointer m_dropboxProvider; // Borrowed via setProvider; owned externally. + QPointer m_remoteSettings; // Borrowed; parent retains ownership. + QJsonObject m_config; // Current Dropbox config loaded from RemoteSettings. + AuthState m_authState = AuthState::Idle; + bool m_modified = false; + bool m_mutualExclusivityActive = false; +}; + +#endif // KEEPASSX_DROPBOXCLOUDSYNCPAGE_H diff --git a/src/gui/remote/dropbox/DropboxCloudSyncPage.ui b/src/gui/remote/dropbox/DropboxCloudSyncPage.ui new file mode 100644 index 0000000000..6a62032ec6 --- /dev/null +++ b/src/gui/remote/dropbox/DropboxCloudSyncPage.ui @@ -0,0 +1,219 @@ + + + DropboxCloudSyncPage + + + + 0 + 0 + 652 + 360 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + App Key: + + + + + + + Dropbox App Key (from developer console) + + + + + + + Remote Path: + + + + + + + /Apps/KeePassXC/passwords.kdbx + + + + + + + Auto sync on save + + + true + + + + + + + Auto sync on open + + + true + + + + + + + + + Status: + + + + + + + Not authorized + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Authorize + + + + + + + Test Connection + + + + + + + Remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Authorization code: + + + + + + + + + + Submit + + + + + + + Cancel + + + + + + + + + + + + Trigger Sync + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/src/gui/remote/nextcloud/NextcloudCloudSyncPage.cpp b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.cpp new file mode 100644 index 0000000000..d1d8865e54 --- /dev/null +++ b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.cpp @@ -0,0 +1,759 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "NextcloudCloudSyncPage.h" +#include "ui_NextcloudCloudSyncPage.h" + +#include "gui/MessageWidget.h" +#include "gui/remote/RemoteSettings.h" +#include "gui/styles/StateColorPalette.h" +#include "remotesync/NextcloudLoginFlow.h" +#include "remotesync/NextcloudSyncProvider.h" +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include + +#include + +const QString NextcloudCloudSyncPage::ConfigName = QStringLiteral("nextcloud-default"); + +NextcloudCloudSyncPage::NextcloudCloudSyncPage(QWidget* parent) + : CloudSyncPage(parent) + , m_ui(new Ui::NextcloudCloudSyncPage()) +{ + m_ui->setupUi(this); + // objectName "nextcloudPage" is assigned externally by + // CloudSyncPage::createBuiltinPages -- same pattern as DropboxCloudSyncPage. + // The factory is the single owner of the page-level objectName so direct + // construction in tests stays anonymous. + + // Click handlers ---------------------------------------------------- + connect(m_ui->authorizeButton, &QPushButton::clicked, this, &NextcloudCloudSyncPage::onAuthorizeClicked); + connect(m_ui->testConnectionButton, &QPushButton::clicked, this, &NextcloudCloudSyncPage::onTestConnectionClicked); + connect(m_ui->removeButton, &QPushButton::clicked, this, &NextcloudCloudSyncPage::onRemoveClicked); + connect(m_ui->triggerSyncButton, &QPushButton::clicked, this, &NextcloudCloudSyncPage::onTriggerSyncClicked); + // appPasswordGroupBox sub-panel buttons. + connect(m_ui->appPasswordAuthorizeButton, + &QPushButton::clicked, + this, + &NextcloudCloudSyncPage::onAppPasswordAuthorizeClicked); + connect(m_ui->openSecurityButton, &QPushButton::clicked, this, &NextcloudCloudSyncPage::onOpenSecurityClicked); + + // Modified signal wiring ------------------------------------------- + // Disabling triggerSyncButton when m_modified flips true ensures + // Sync Now obeys the "click Apply first" banners -- syncing reads + // from RemoteSettings (persisted), not page state, so an unsaved + // edit would either no-op or fire stale credentials. + auto markModified = [this] { + m_modified = true; + m_ui->triggerSyncButton->setEnabled(false); + emit modified(); + }; + connect(m_ui->serverBaseUrlEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->remotePathEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->syncOnSaveCheckBox, &QCheckBox::toggled, this, markModified); + connect(m_ui->syncOnOpenCheckBox, &QCheckBox::toggled, this, markModified); + // 3 editable widgets in the appPasswordGroupBox sub-panel. The QGroupBox itself + // emits toggled() when the user expands/collapses the sub-panel -- that's also a + // modification to the page state from a user-edit perspective. loadFromConfig blocks + // all 3 of these signals so auto-expand on dialog open does not falsely flip + // m_modified. + connect(m_ui->loginNameEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->appPasswordEdit, &QLineEdit::textChanged, this, markModified); + connect(m_ui->appPasswordGroupBox, &QGroupBox::toggled, this, [this](bool) { + m_modified = true; + m_ui->triggerSyncButton->setEnabled(false); + emit modified(); + }); + + // Default browser opener -- production path. Tests override via + // setBrowserOpener. Mirrors NextcloudLoginFlow's default-opener shape. + m_browserOpener = [](const QUrl& url) { QDesktopServices::openUrl(url); }; + + // No ctor-time updateAuthStatus(Idle) -- DatabaseSettingsWidgetCloudSync + // calls loadFromConfig before showing the page, which drives the auth + // status row from persisted state. Matches DropboxCloudSyncPage. +} + +NextcloudCloudSyncPage::~NextcloudCloudSyncPage() = default; + +// --------------------------------------------------------------------------- +// CloudSyncPage contract overrides +// --------------------------------------------------------------------------- + +void NextcloudCloudSyncPage::setProvider(RemoteSyncProvider* provider) +{ + // Borrowed pointer; cast to concrete type for Nextcloud-specific methods + // (Login Flow v2 / app-password orchestration -- not on the abstract base + // since they're protocol-specific). qobject_cast inside a Nextcloud*-named + // file is the legitimate exemption (mirrors DropboxCloudSyncPage::setProvider). + // The provider's lifetime is owned by the widget that constructed it via + // RemoteSyncProvider::create. + m_nextcloudProvider = qobject_cast(provider); +} + +QString NextcloudCloudSyncPage::providerType() const +{ + return QStringLiteral("nextcloud"); +} + +QString NextcloudCloudSyncPage::providerDisplayName() const +{ + // Untranslated brand identifier, matching DropboxCloudSyncPage and + // RemoteSyncProvider::displayName's "UI applies tr() at the call site" + // contract. The dropdown population code is the single tr() site. + return QStringLiteral("Nextcloud"); +} + +void NextcloudCloudSyncPage::loadFromConfig(const QJsonObject& config) +{ + m_config = config; + + // Block signals while populating UI from config to avoid false + // modified-flag triggers (mirrors DropboxCloudSyncPage::loadFromConfig + // precedent). + const QSignalBlocker serverUrlBlocker(m_ui->serverBaseUrlEdit); + const QSignalBlocker remotePathBlocker(m_ui->remotePathEdit); + const QSignalBlocker syncOnSaveBlocker(m_ui->syncOnSaveCheckBox); + const QSignalBlocker syncOnOpenBlocker(m_ui->syncOnOpenCheckBox); + // The appPasswordGroupBox blocker is load-bearing -- if the user had + // checked the box in a prior session (paste path) and we now collapse + // it, the unblocked setChecked(false) would fire QGroupBox::toggled + // -> markModified, falsely enabling the parent's Apply button on a + // mere dialog reopen. + const QSignalBlocker loginNameBlocker(m_ui->loginNameEdit); + const QSignalBlocker appPasswordBlocker(m_ui->appPasswordEdit); + const QSignalBlocker appPasswordGroupBoxBlocker(m_ui->appPasswordGroupBox); + + m_ui->serverBaseUrlEdit->setText(m_config[QStringLiteral("serverBaseUrl")].toString()); + m_ui->remotePathEdit->setText(m_config[QStringLiteral("remotePath")].toString()); + m_ui->syncOnSaveCheckBox->setChecked(m_config.value(QStringLiteral("syncOnSave")).toBool(true)); + m_ui->syncOnOpenCheckBox->setChecked(m_config.value(QStringLiteral("syncOnOpen")).toBool(true)); + + // Populate sub-panel fields from m_config but always leave the QGroupBox + // collapsed. The checkbox represents the user's explicit choice to use + // the paste-creds path INSTEAD of the top-level Authorize button (Login + // Flow v2) -- it is NOT derived from whether creds happen to be + // persisted. After a successful Login Flow v2 authorization (or a paste + // saved in a prior session), loginNameEdit / appPasswordEdit are + // populated but stay visible-but-grayed (QGroupBox checkable+unchecked + // -> children disabled), so the user sees the saved creds without being + // able to edit them by accident. Clicking the box is the user's + // affirmative "I want to edit/paste" action. + const QString loginName = m_config[QStringLiteral("loginName")].toString(); + const QString appPassword = m_config[QStringLiteral("appPassword")].toString(); + m_ui->loginNameEdit->setText(loginName); + m_ui->appPasswordEdit->setText(appPassword); + m_ui->appPasswordGroupBox->setChecked(false); + + // Drive the auth-status row from persisted creds. If both fields are + // populated, the page reopens directly into the Authorized state + // ("Authorized as " + button text "Remove"). The + // loginCompleted slot writes those keys after a successful Login Flow v2; + // this branch handles the dialog-reopen path. + if (!loginName.isEmpty() && !appPassword.isEmpty()) { + m_authState = AuthState::Authorized; + } else { + m_authState = AuthState::Idle; + } + // Reset m_modified BEFORE updateAuthStatus so the Authorized branch's + // triggerSyncButton gate sees the post-load state (clean, not dirty). + m_modified = false; + updateAuthStatus(m_authState); +} + +QJsonObject NextcloudCloudSyncPage::saveToConfig() const +{ + // Fresh-no-edit fast path (mirrors DropboxCloudSyncPage::saveToConfig): + // every editable field empty AND no cached config means the user opened + // the dialog, selected Nextcloud in the combo, and never touched + // anything. Return an empty object so the parent skips persistence -- + // otherwise the always-populated type/name keys below would mark + // Nextcloud as the active provider with no credentials, leaving cloud + // sync "configured but unusable." + if (m_ui->serverBaseUrlEdit->text().trimmed().isEmpty() + && m_ui->remotePathEdit->text().trimmed().isEmpty() + && m_ui->loginNameEdit->text().trimmed().isEmpty() + && m_ui->appPasswordEdit->text().isEmpty() + && m_config.isEmpty()) { + return QJsonObject(); + } + + // Merge over m_config (rather than replacing) to preserve any keys set by + // the authorization paths (loginName / appPassword from onLoginCompleted + // or onAppPasswordAuthorizeClicked) so successive loadFromConfig sees the + // persisted creds. + QJsonObject config = m_config; + config[QStringLiteral("type")] = providerType(); + config[QStringLiteral("name")] = ConfigName; + config[QStringLiteral("serverBaseUrl")] = m_ui->serverBaseUrlEdit->text().trimmed(); + config[QStringLiteral("remotePath")] = NextcloudSyncProvider::normalizeRemotePath(m_ui->remotePathEdit->text()); + config[QStringLiteral("syncOnSave")] = m_ui->syncOnSaveCheckBox->isChecked(); + config[QStringLiteral("syncOnOpen")] = m_ui->syncOnOpenCheckBox->isChecked(); + + // App-password sub-panel fields are the "paste-without-clicking-Authorize- + // with-AppPassword" path. Only let them override m_config when they + // actually contain something -- otherwise an empty sub-panel (Login + // Flow v2 user who never touched the sub-panel) would wipe the creds + // onLoginCompleted just persisted to m_config. App-password is NOT + // trimmed because whitespace might be significant for app-password + // values. + const QString uiLoginName = m_ui->loginNameEdit->text().trimmed(); + const QString uiAppPassword = m_ui->appPasswordEdit->text(); + if (!uiLoginName.isEmpty()) { + config[QStringLiteral("loginName")] = uiLoginName; + } + if (!uiAppPassword.isEmpty()) { + config[QStringLiteral("appPassword")] = uiAppPassword; + } + + return config; +} + +bool NextcloudCloudSyncPage::isModified() const +{ + return m_modified; +} + +void NextcloudCloudSyncPage::setRemoteSettings(RemoteSettings* settings) +{ + m_remoteSettings = settings; +} + +void NextcloudCloudSyncPage::setBrowserOpener(BrowserOpener opener) +{ + // Test seam -- mirrors NextcloudLoginFlow::setBrowserOpener. Empty + // std::function would silently no-op the security deep-link; ignore + // that case so production never accidentally clears the default lambda. + if (opener) { + m_browserOpener = std::move(opener); + } +} + +void NextcloudCloudSyncPage::setLoginFlowForTest(NextcloudLoginFlow* flow) +{ + // Test seam: caller hands off ownership; we reparent and wire signals so + // the page treats it like the lazily-constructed default. Mirror of + // DropboxCloudSyncPage::setLoginFlowForTest. + m_loginFlow.reset(flow); + if (flow) { + flow->setParent(this); + connect(flow, &NextcloudLoginFlow::loginInitiated, this, &NextcloudCloudSyncPage::onLoginInitiated); + connect(flow, &NextcloudLoginFlow::loginCompleted, this, &NextcloudCloudSyncPage::onLoginCompleted); + connect(flow, &NextcloudLoginFlow::loginFailed, this, &NextcloudCloudSyncPage::onLoginFailed); + connect(flow, &NextcloudLoginFlow::loginCancelled, this, &NextcloudCloudSyncPage::onLoginCancelled); + } +} + +void NextcloudCloudSyncPage::setMutualExclusivityWarning(bool active) +{ + // Disable fields under the warning so the user cannot edit Nextcloud + // config while Script Sync is also configured (mirrors Dropbox). + m_mutualExclusivityActive = active; + setFieldsEnabled(!active); +} + +void NextcloudCloudSyncPage::setFieldsEnabled(bool enabled) +{ + m_ui->serverBaseUrlEdit->setEnabled(enabled); + m_ui->remotePathEdit->setEnabled(enabled); + m_ui->authorizeButton->setEnabled(enabled); + m_ui->testConnectionButton->setEnabled(enabled); + m_ui->removeButton->setEnabled(enabled); + m_ui->syncOnSaveCheckBox->setEnabled(enabled); + m_ui->syncOnOpenCheckBox->setEnabled(enabled); + m_ui->appPasswordGroupBox->setEnabled(enabled); +} + +// --------------------------------------------------------------------------- +// Click handlers +// --------------------------------------------------------------------------- + +void NextcloudCloudSyncPage::onAuthorizeClicked() +{ + // The top-level Authorize button is overloaded by AuthState: + // Idle -> begin Login Flow v2 + // Authorizing -> cancel the in-flight flow + // Authorized -> route to Remove handler (button text reads "Remove" in + // Authorized state; Login Flow v2 has no server-side + // revoke endpoint, so "Remove" clears local config only) + if (m_authState == AuthState::Authorized) { + onRemoveClicked(); + return; + } + + if (m_authState == AuthState::Authorizing) { + // NextcloudLoginFlow::cancel is idempotent on Idle and emits + // loginCancelled exactly once when it stops an active flow. The + // loginCancelled signal will reset our AuthState via + // onLoginCancelled. + m_loginFlow->cancel(); + return; + } + + // Idle: validate input, then start Login Flow v2. Single checkpoint via + // the shared helper -- Empty / NotSecure / Malformed all dispatched there + // with their own banner; we proceed only on Ok. + const QString serverUrl = m_ui->serverBaseUrlEdit->text().trimmed(); + QString canonical; + if (!validateAndCanonicalizeServerUrl(serverUrl, canonical)) { + return; + } + + // Lazy-construct NextcloudLoginFlow on first Idle Authorize click; reuse + // the same instance on subsequent clicks. QScopedPointer gives + // deterministic destruction; startLoginFlow is internally + // cancel-previous, so reuse has equivalent semantics with less destructor + // churn. + if (!m_loginFlow) { + m_loginFlow.reset(new NextcloudLoginFlow(this)); + connect( + m_loginFlow.data(), &NextcloudLoginFlow::loginInitiated, this, &NextcloudCloudSyncPage::onLoginInitiated); + connect( + m_loginFlow.data(), &NextcloudLoginFlow::loginCompleted, this, &NextcloudCloudSyncPage::onLoginCompleted); + connect(m_loginFlow.data(), &NextcloudLoginFlow::loginFailed, this, &NextcloudCloudSyncPage::onLoginFailed); + connect( + m_loginFlow.data(), &NextcloudLoginFlow::loginCancelled, this, &NextcloudCloudSyncPage::onLoginCancelled); + } + + updateAuthStatus(AuthState::Authorizing); + setFieldsEnabled(false); + // Keep the Authorize button live so its "Cancel Authorization" label is clickable. + m_ui->authorizeButton->setEnabled(true); + m_loginFlow->startLoginFlow(serverUrl); +} + +void NextcloudCloudSyncPage::onAppPasswordAuthorizeClicked() +{ + const QString serverUrl = m_ui->serverBaseUrlEdit->text().trimmed(); + QString canonical; + if (!validateAndCanonicalizeServerUrl(serverUrl, canonical)) { + return; + } + + const QString loginName = m_ui->loginNameEdit->text().trimmed(); + // App-password is NOT trimmed -- whitespace might be significant. + const QString appPassword = m_ui->appPasswordEdit->text(); + if (loginName.isEmpty() || appPassword.isEmpty()) { + emit showMessage(tr("Enter both username and app-password."), MessageWidget::Warning, false); + return; + } + + // Build params from the SUB-PANEL pasted creds (NOT from m_config). The + // QGroupBox sub-panel is the explicit pasted-validation path; the user + // typed these values right above this button and expects them to be the + // source of truth for THIS click. + auto params = std::make_unique(); + params->type = providerType(); + params->name = ConfigName; + params->serverBaseUrl = canonical; + params->remotePath = NextcloudSyncProvider::normalizeRemotePath(m_ui->remotePathEdit->text()); + params->loginName = loginName; + params->appPassword = appPassword; + params->timeoutMsec = 30000; + + // Reentrancy guard. NextcloudSyncProvider::testConnection is synchronous + // via an internal QEventLoop. If the user closes the dialog while the + // PROPFIND is in flight, the page widget is destroyed under us; + // dereferencing `this` after the blocking call returns would segfault. + // The QPointer tracks widget lifetime; we bail out without touching + // `this` if it's been deleted. + QPointer guard(this); + + setFieldsEnabled(false); + // Keep the appPasswordAuthorizeButton itself disabled too -- setFieldsEnabled disables + // the QGroupBox, which propagates to the button, but be explicit so a future + // setFieldsEnabled refactor doesn't accidentally leave this button live during the + // blocking call. + m_ui->appPasswordAuthorizeButton->setEnabled(false); + + if (!m_nextcloudProvider) { + return; + } + RemoteHandler::RemoteResult result = m_nextcloudProvider->testConnection(params.get()); + + if (!guard) { + // Widget destroyed during the call -- bail without dereferencing this. + return; + } + + setFieldsEnabled(true); + m_ui->appPasswordAuthorizeButton->setEnabled(true); + + if (result.success) { + // Persist to m_config (last-write-wins, single-slot semantics). Mirror + // serverBaseUrl + remotePath from UI too so onTestConnectionClicked + // (which reads from m_config) sees a fully-consistent post-Authorize + // state. Do NOT call m_remoteSettings->saveSettings() here -- the + // user's Apply click is the persist gate + // (DatabaseSettingsWidgetCloudSync::saveSettings). m_modified + emit + // modified() is what actually flips the parent's Apply button enabled + // state. + m_config[QStringLiteral("loginName")] = loginName; + m_config[QStringLiteral("appPassword")] = appPassword; + m_config[QStringLiteral("serverBaseUrl")] = serverUrl; + m_config[QStringLiteral("remotePath")] = NextcloudSyncProvider::normalizeRemotePath(m_ui->remotePathEdit->text()); + m_modified = true; + emit modified(); + + updateAuthStatus(AuthState::Authorized); + + // KEEP the QGroupBox open after success so the user can verify their + // persisted creds. Do NOT call appPasswordGroupBox->setChecked(false). + // User mental model: "the creds are right here in front of me, I can + // verify them." + + emit showMessage(tr("Authorization successful, click Apply to save."), MessageWidget::Positive, false); + } else { + // The provider's classifyError + mapWebdavStatusToMessage chain + // already produces the locked verbatim credential-rejection banner. + // Forward result.errorMessage verbatim (already tr()'d at emit site). + emit showMessage(result.errorMessage, MessageWidget::Error, false); + // Do NOT persist on failure: validation failure surfaces a clear + // error and credentials are NOT persisted. m_config is unchanged; + // m_modified is also unchanged so the parent's Apply button stays in + // whatever state the user left it. + } +} + +void NextcloudCloudSyncPage::onOpenSecurityClicked() +{ + QString canonical; + if (!validateAndCanonicalizeServerUrl(m_ui->serverBaseUrlEdit->text().trimmed(), canonical)) { + return; + } + + // Use the canonical base, NOT buildResourceUrl: buildResourceUrl produces + // /remote.php/dav/files// (the WebDAV per-user + // files endpoint); the Security page is at the application root. The + // canonical form has already had its trailing slash stripped by the + // helper, so the concat below cannot produce a double-slash regardless + // of what the user typed. + const QUrl securityUrl(canonical + QStringLiteral("/settings/user/security")); + + // Production: m_browserOpener is QDesktopServices::openUrl; tests inject + // a capture lambda via setBrowserOpener(). NO qDebug printing the URL -- + // no-secrets-in-logs (the Security page URL is at the app root and + // doesn't carry a token, but the policy blanket-covers all auth-context + // URL logging). + m_browserOpener(securityUrl); +} + +void NextcloudCloudSyncPage::onTestConnectionClicked() +{ + // Saved-creds path (mirrors Dropbox onTestConnectionClicked). The + // sub-panel's "Authorize with App Password" button covers pasted-creds + // validation; this top-level Test Connection button uses the persisted + // m_config creds so the user can verify the saved-creds path works + // without re-typing. + const QString loginName = m_config[QStringLiteral("loginName")].toString(); + const QString appPassword = m_config[QStringLiteral("appPassword")].toString(); + const QString serverUrl = m_config[QStringLiteral("serverBaseUrl")].toString(); + + if (serverUrl.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) { + emit showMessage(tr("Authorize Nextcloud first to test the connection."), MessageWidget::Warning, false); + return; + } + + auto params = std::make_unique(); + params->type = providerType(); + params->name = ConfigName; + params->serverBaseUrl = NextcloudSyncProvider::canonicalizeServerBaseUrl(serverUrl); + params->remotePath = m_config[QStringLiteral("remotePath")].toString(); + params->loginName = loginName; + params->appPassword = appPassword; + params->timeoutMsec = 30000; + + // Reentrancy guard (see onAppPasswordAuthorizeClicked above; same + // QEventLoop synchronous-call pattern). + QPointer guard(this); + setFieldsEnabled(false); + m_ui->testConnectionButton->setEnabled(false); + + if (!m_nextcloudProvider) { + return; + } + RemoteHandler::RemoteResult result = m_nextcloudProvider->testConnection(params.get()); + + if (!guard) { + // Widget destroyed during the call -- bail without dereferencing this. + return; + } + + setFieldsEnabled(true); + m_ui->testConnectionButton->setEnabled(true); + + if (result.success) { + // Mirror Dropbox: empty filePath signals the first-sync case (404 -- + // auth OK, file not yet on server). Differentiate the message + // accordingly. + if (!result.filePath.isEmpty()) { + emit showMessage(tr("Nextcloud connection successful."), MessageWidget::Positive, false); + } else { + emit showMessage( + tr("Connected. File not found -- it will be created on first sync."), MessageWidget::Positive, false); + } + return; + } + + // Failure: classifyError dispatch -> user-friendly banner family. Mirror + // DropboxCloudSyncPage verbatim with Nextcloud-flavored field reads. The + // 4 friendliest ErrorKinds get per-kind tr() text; the rest fall through + // to the verbatim banner from mapWebdavStatusToMessage (Conflict / + // RateLimit / Permission / Quota / ServerError / Other are preserved + // as-is). + QString errorMsg = result.errorMessage; + // Prefer the kind set by the provider at the source. Falling back to + // classifyError(errorMsg) parses tr()'d English fragments and silently + // misses on localized builds. + auto kind = result.kind; + // Dialog may have been torn down during the nested event loop in testConnection(). + if (kind == RemoteSyncProvider::ErrorKind::Other && m_nextcloudProvider) { + kind = m_nextcloudProvider->classifyError(errorMsg); + } + if (kind == RemoteSyncProvider::ErrorKind::AuthExpired || kind == RemoteSyncProvider::ErrorKind::AuthRevoked) { + errorMsg = tr("Authorization expired. Re-authorize."); + } else if (kind == RemoteSyncProvider::ErrorKind::Network) { + errorMsg = tr("Network error: timeout."); + } else if (kind == RemoteSyncProvider::ErrorKind::NotFound) { + errorMsg = tr("Remote path not found."); + } + emit showMessage(errorMsg, MessageWidget::Error, false); +} + +void NextcloudCloudSyncPage::onRemoveClicked() +{ + // No server-side revoke. App-passwords don't have a public revoke + // endpoint; the user revokes from the Nextcloud Security page (the + // deep-link button is in the QGroupBox sub-panel). Clear local config + // only. + // + // No QMessageBox confirmation here. The user can re-Authorize in one + // click + browser grant. + + updateAuthStatus(AuthState::Removing); + + if (!m_remoteSettings) { + return; + } + m_remoteSettings->removeProviderConfig(QStringLiteral("nextcloud"), ConfigName); + m_remoteSettings->saveSettings(); + m_config = QJsonObject(); + + // Clear UI fields. QSignalBlockers prevent the clears from firing + // markModified; otherwise each clear() would emit textChanged -> + // markModified -> emit modified() -> noisy state churn. + { + const QSignalBlocker serverUrlBlocker(m_ui->serverBaseUrlEdit); + const QSignalBlocker remotePathBlocker(m_ui->remotePathEdit); + const QSignalBlocker loginNameBlocker(m_ui->loginNameEdit); + const QSignalBlocker appPasswordBlocker(m_ui->appPasswordEdit); + const QSignalBlocker groupBoxBlocker(m_ui->appPasswordGroupBox); + // syncOnSaveCheckBox / syncOnOpenCheckBox INTENTIONALLY NOT CLEARED -- those + // are user preferences, not credentials. Mirrors Dropbox onRemoveClicked. + m_ui->serverBaseUrlEdit->clear(); + m_ui->remotePathEdit->clear(); + m_ui->loginNameEdit->clear(); + m_ui->appPasswordEdit->clear(); + m_ui->appPasswordGroupBox->setChecked(false); + } + + updateAuthStatus(AuthState::Idle); + + // Two-sentence post-Remove banner. Justified Dropbox-deviation: + // structural difference is no server-side revoke endpoint, so the user + // needs to know the next step. C++ adjacent-string-literal concatenation + // joins these two pieces into a single banner at compile time; the + // source split is purely a clang-format ColumnLimit:120 accommodation. + emit showMessage(tr("Nextcloud configuration removed. " + "To revoke the app-password server-side, visit your Nextcloud Security page."), + MessageWidget::Positive, + false); + + // After Remove, the page state matches the now-removed RemoteSettings entry. Apply + // button should disable. (saveSettings() above already persisted the removal; + // m_modified=false signals the parent widget that nothing further needs persisting.) + m_modified = false; +} + +void NextcloudCloudSyncPage::onTriggerSyncClicked() +{ + emit requestSync(); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +void NextcloudCloudSyncPage::updateAuthStatus(AuthState state) +{ + m_authState = state; + + StateColorPalette statePalette; + const QString boldGreen = + QStringLiteral("font-weight: bold; color: %1;").arg(statePalette.color(StateColorPalette::True).name()); + const QString boldRed = + QStringLiteral("font-weight: bold; color: %1;").arg(statePalette.color(StateColorPalette::False).name()); + + switch (state) { + case AuthState::Idle: + m_ui->authStatusLabel->setText(tr("Not authorized")); + m_ui->authStatusLabel->setStyleSheet(boldRed); + m_ui->authorizeButton->setText(tr("Authorize")); + m_ui->triggerSyncButton->setEnabled(false); + break; + + case AuthState::Authorizing: + // Verbatim Dropbox status copy. + m_ui->authStatusLabel->setText(tr("Waiting for browser authorization...")); + m_ui->authStatusLabel->setStyleSheet(QString()); + m_ui->authorizeButton->setText(tr("Cancel Authorization")); + m_ui->triggerSyncButton->setEnabled(false); + break; + + case AuthState::Authorized: + m_ui->authStatusLabel->setText(tr("Authorized as %1").arg(m_config[QStringLiteral("loginName")].toString())); + m_ui->authStatusLabel->setStyleSheet(boldGreen); + // Button text in Authorized state is "Remove" (NOT "Revoke") for + // Nextcloud, since Login Flow v2 has no server-side revoke endpoint + // -- the user removes the local app-password instead. + m_ui->authorizeButton->setText(tr("Remove")); + // Sync Now stays disabled while the page has unsaved edits or a + // just-completed auth that hasn't been Applied -- syncing reads + // persisted RemoteSettings, not page state. + m_ui->triggerSyncButton->setEnabled(!m_modified); + break; + + case AuthState::Removing: + m_ui->authStatusLabel->setText(tr("Removing...")); + m_ui->authStatusLabel->setStyleSheet(QString()); + m_ui->triggerSyncButton->setEnabled(false); + break; + } +} + +// --------------------------------------------------------------------------- +// NextcloudLoginFlow signal-slot bodies +// --------------------------------------------------------------------------- + +void NextcloudCloudSyncPage::onLoginInitiated(const QUrl& loginUrl) +{ + Q_UNUSED(loginUrl); + // Intentional no-op. The status row was already set to "Waiting for + // browser authorization..." when onAuthorizeClicked called + // updateAuthStatus(AuthState::Authorizing). No bridge text during the + // browser-open phase. + // + // No qDebug printing loginUrl -- no-secrets-in-logs (loginUrl carries + // the polling token in its query/fragment). +} + +void NextcloudCloudSyncPage::onLoginCompleted(const QString& loginName, const QString& appPassword) +{ + // Persist creds to m_config (last-write-wins, single-slot semantics). + // Also mirror serverBaseUrl + remotePath from the UI into m_config so + // the post-Authorize m_config is fully consistent -- + // onTestConnectionClicked reads serverBaseUrl from m_config, and would + // otherwise warn "Authorize first" even though the page is already in + // Authorized state. + // + // Do NOT call m_remoteSettings->saveSettings() here -- that would + // trigger databaseSaved -> sync-on-save while the dialog is still open, + // causing a duplicate sync. The user's Apply click is the persist gate + // (DatabaseSettingsWidgetCloudSync::saveSettings). + m_config[QStringLiteral("loginName")] = loginName; + m_config[QStringLiteral("appPassword")] = appPassword; + m_config[QStringLiteral("serverBaseUrl")] = m_ui->serverBaseUrlEdit->text().trimmed(); + m_config[QStringLiteral("remotePath")] = NextcloudSyncProvider::normalizeRemotePath(m_ui->remotePathEdit->text()); + m_modified = true; + emit modified(); + + // Mirror the just-received creds into the sub-panel UI fields so the user sees + // "Authorized as " AND the populated sub-panel without having to close + // and reopen the dialog. QSignalBlockers prevent textChanged -> markModified + // from re-emitting modified() (already emitted above with the correct intent). + { + const QSignalBlocker loginNameBlocker(m_ui->loginNameEdit); + const QSignalBlocker appPasswordBlocker(m_ui->appPasswordEdit); + m_ui->loginNameEdit->setText(loginName); + m_ui->appPasswordEdit->setText(appPassword); + } + + setFieldsEnabled(true); + // updateAuthStatus(Authorized) renders "Authorized as " and + // flips the top button text to "Remove". + updateAuthStatus(AuthState::Authorized); + + emit showMessage(tr("Authorization successful, click Apply to save."), MessageWidget::Positive, false); +} + +void NextcloudCloudSyncPage::onLoginFailed(const QString& reason) +{ + setFieldsEnabled(true); + updateAuthStatus(AuthState::Idle); + + // `reason` is verbatim from NextcloudLoginFlow's banner constants + // (timeout / initiate-fail / network-error / authcheck-fail / cancelled + // strings, already tr()'d at the emit sites). Do NOT re-tr() here -- it + // would create a duplicate translation site for the same string. + emit showMessage(reason, MessageWidget::Error, false); +} + +void NextcloudCloudSyncPage::onLoginCancelled() +{ + setFieldsEnabled(true); + updateAuthStatus(AuthState::Idle); + // Cancellation is silent (the user clicked Cancel themselves; surfacing + // a banner would be redundant). +} + +bool NextcloudCloudSyncPage::validateAndCanonicalizeServerUrl(const QString& input, QString& canonicalOut) +{ + using V = NextcloudSyncProvider::ServerUrlValidity; + QString canonical; + const V result = NextcloudSyncProvider::validateServerUrl(input, &canonical); + switch (result) { + case V::Ok: + canonicalOut = canonical; + return true; + case V::Empty: + // Warning, not Error -- this is a "you forgot to type" prompt, not a + // rejected attempt. Verbatim banner shared with the two other handlers + // (user mental model: same prompt regardless of which button they hit). + emit showMessage(tr("Enter the Nextcloud server URL first."), MessageWidget::Warning, false); + return false; + case V::NotSecure: + // Error severity: this is an actionable rejection ("change your input + // to https://"), not just a prompt. The banner is the only place that + // surfaces the cleartext-policy reason; downstream layers (canonicalize + // -> empty -> generic "URL is required") would lose this discrimination. + emit showMessage(tr("Plain HTTP is only allowed for a loopback address (localhost / 127.0.0.1 / [::1]). " + "Use https:// so your Nextcloud app password is not sent in cleartext."), + MessageWidget::Error, + false); + return false; + case V::Malformed: + emit showMessage(tr("Invalid Nextcloud server URL."), MessageWidget::Warning, false); + return false; + } + // Unreachable: all enum cases are handled above. The return satisfies + // compilers that don't recognize the switch as exhaustive. + return false; +} diff --git a/src/gui/remote/nextcloud/NextcloudCloudSyncPage.h b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.h new file mode 100644 index 0000000000..f841b206d6 --- /dev/null +++ b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.h @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_NEXTCLOUDCLOUDSYNCPAGE_H +#define KEEPASSX_NEXTCLOUDCLOUDSYNCPAGE_H + +#include "gui/remote/CloudSyncPage.h" + +#include +#include +#include + +#include + +class QUrl; + +namespace Ui +{ + class NextcloudCloudSyncPage; +} + +class NextcloudLoginFlow; +class NextcloudSyncProvider; +class RemoteSettings; +class RemoteSyncProvider; + +class NextcloudCloudSyncPage : public CloudSyncPage +{ + Q_OBJECT + Q_DISABLE_COPY(NextcloudCloudSyncPage) + +public: + explicit NextcloudCloudSyncPage(QWidget* parent = nullptr); + ~NextcloudCloudSyncPage() override; + + // CloudSyncPage contract ---------------------------------------------- + void setProvider(RemoteSyncProvider* provider) override; + QString providerType() const override; + QString providerDisplayName() const override; + void loadFromConfig(const QJsonObject& config) override; + QJsonObject saveToConfig() const override; + bool isModified() const override; + void setRemoteSettings(RemoteSettings* settings) override; + void setMutualExclusivityWarning(bool active) override; + + static const QString ConfigName; + + // Test seam mirroring NextcloudLoginFlow::setBrowserOpener. Production + // default invokes QDesktopServices::openUrl. Tests inject a capture + // lambda to assert on the URL without launching a real browser. Empty + // std::function is ignored so production never accidentally clears the + // default lambda. + using BrowserOpener = std::function; + void setBrowserOpener(BrowserOpener opener); + + // Test seam: inject a NextcloudLoginFlow (typically a MockNextcloudLoginFlow) + // BEFORE the first Authorize click. Production lazy-constructs the default + // on first click via onAuthorizeClicked. Page takes ownership. + // Mirrors DropboxCloudSyncPage::setLoginFlowForTest. + void setLoginFlowForTest(NextcloudLoginFlow* flow); + +private slots: + void onAuthorizeClicked(); + void onAppPasswordAuthorizeClicked(); + void onOpenSecurityClicked(); + void onTestConnectionClicked(); + void onRemoveClicked(); + void onTriggerSyncClicked(); + + // 4 of 5 NextcloudLoginFlow signals -- pollingTick is intentionally NOT + // wired. + void onLoginInitiated(const QUrl& loginUrl); + void onLoginCompleted(const QString& loginName, const QString& appPassword); + void onLoginFailed(const QString& reason); + void onLoginCancelled(); + +private: + enum class AuthState + { + Idle, + Authorizing, + Authorized, + Removing + }; + + void updateAuthStatus(AuthState state); + void setFieldsEnabled(bool enabled); + + // Validate a user-typed server URL via NextcloudSyncProvider::validateServerUrl + // and dispatch the per-case banner. On Ok: writes the canonical form to + // canonicalOut and returns true. On Empty/NotSecure/Malformed: emits the + // appropriate showMessage and returns false (canonicalOut untouched). + // + // Single entry point shared by onAuthorize / onAppPasswordAuthorize / + // onOpenSecurity so all three handlers surface the same dispatch for + // the same input. + bool validateAndCanonicalizeServerUrl(const QString& input, QString& canonicalOut); + + QScopedPointer m_ui; + QScopedPointer m_loginFlow; // lazily instantiated on first Authorize + QPointer m_nextcloudProvider; // Borrowed via setProvider; owned externally + QPointer m_remoteSettings; // Borrowed; parent retains ownership + QJsonObject m_config; // Current Nextcloud config loaded from RemoteSettings + BrowserOpener m_browserOpener; // Production default = QDesktopServices::openUrl + AuthState m_authState{AuthState::Idle}; + bool m_modified{false}; + bool m_mutualExclusivityActive{false}; // True when Script Sync is configured -- gates field re-enables +}; + +#endif // KEEPASSX_NEXTCLOUDCLOUDSYNCPAGE_H diff --git a/src/gui/remote/nextcloud/NextcloudCloudSyncPage.ui b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.ui new file mode 100644 index 0000000000..dc5bb3d6ea --- /dev/null +++ b/src/gui/remote/nextcloud/NextcloudCloudSyncPage.ui @@ -0,0 +1,251 @@ + + + NextcloudCloudSyncPage + + + + 0 + 0 + 652 + 420 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Server URL: + + + + + + + https://example.com/nextcloud/ + + + + + + + Remote Path: + + + + + + + /Passwords/Database.kdbx + + + + + + + Auto sync on save + + + true + + + + + + + Auto sync on open + + + true + + + + + + + + + Status: + + + + + + + Not authorized + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Authorize + + + + + + + Test Connection + + + + + + + Remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Use App Password Instead + + + true + + + false + + + + + + Username: + + + + + + + alice + + + + + + + App Password: + + + + + + + QLineEdit::Password + + + xxxx-xxxx-xxxx-xxxx + + + + + + + + + Open Nextcloud Security + + + + + + + Authorize with App Password + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Trigger Sync + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/src/remotesync/CMakeLists.txt b/src/remotesync/CMakeLists.txt new file mode 100644 index 0000000000..fa69669479 --- /dev/null +++ b/src/remotesync/CMakeLists.txt @@ -0,0 +1,31 @@ +include_directories(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}) + +# Always-built foundation: provider abstraction + sync engine + command +# provider. Free of Qt6::Network so the command-based ("Script") sync flow +# that DatabaseWidget routes through RemoteSyncProvider::create("command", ...) +# remains compiled even when networking is disabled at build time. +set(remotesync_SOURCES + RemoteSyncProvider.cpp + CommandSyncProvider.cpp + SyncEngine.cpp +) + +set(remotesync_LIBS Qt6::Core Qt6::Widgets) + +# Cloud providers require Qt6::Network. They drop out when the project is +# built with -DKPXC_FEATURE_NETWORK=OFF, the same flag that already governs +# update check and other network-using subsystems. +if(KPXC_FEATURE_NETWORK) + list(APPEND remotesync_SOURCES + HttpRetryHelper.cpp + DropboxSyncProvider.cpp + DropboxLoginFlow.cpp + NextcloudSyncProvider.cpp + NextcloudLoginFlow.cpp + OAuthHttpServer.cpp + ) + list(APPEND remotesync_LIBS Qt6::Network) +endif() + +add_library(remotesync STATIC ${remotesync_SOURCES}) +target_link_libraries(remotesync ${remotesync_LIBS}) diff --git a/src/remotesync/CommandSyncProvider.cpp b/src/remotesync/CommandSyncProvider.cpp new file mode 100644 index 0000000000..8677602a1e --- /dev/null +++ b/src/remotesync/CommandSyncProvider.cpp @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "CommandSyncProvider.h" + +#include "RemoteSyncParams.h" +#include "gui/remote/RemoteHandler.h" +#include "gui/remote/RemoteSettings.h" + +CommandSyncProvider::CommandSyncProvider(QObject* parent) + : RemoteSyncProvider(parent) + , m_handler(new RemoteHandler(this)) +{ +} + +RemoteParams* CommandSyncProvider::toRemoteParams(const RemoteSyncParams* params) const +{ + // Safe: the factory and buildParamsFromConfig pair providers and params + // by type, so a CommandSyncProvider only ever receives CommandSyncParams. + auto* cmdParams = static_cast(params); + + auto* remoteParams = new RemoteParams(); + remoteParams->name = cmdParams->name; + remoteParams->downloadCommand = cmdParams->downloadCommand; + remoteParams->downloadInput = cmdParams->downloadInput; + remoteParams->downloadTimeoutMsec = cmdParams->downloadTimeoutMsec; + remoteParams->uploadCommand = cmdParams->uploadCommand; + remoteParams->uploadInput = cmdParams->uploadInput; + remoteParams->uploadTimeoutMsec = cmdParams->uploadTimeoutMsec; + return remoteParams; +} + +RemoteHandler::RemoteResult CommandSyncProvider::download(const RemoteSyncParams* params) +{ + QScopedPointer remoteParams(toRemoteParams(params)); + return m_handler->download(remoteParams.data()); +} + +RemoteHandler::RemoteResult CommandSyncProvider::upload(const QString& filePath, const RemoteSyncParams* params) +{ + QScopedPointer remoteParams(toRemoteParams(params)); + return m_handler->upload(filePath, remoteParams.data()); +} + +RemoteHandler::RemoteResult CommandSyncProvider::refreshAuth(const RemoteSyncParams* params) +{ + Q_UNUSED(params) + // Command-based providers do not have auth refresh -- return success as no-op + return RemoteHandler::RemoteResult{true, {}, {}, {}, {}}; +} + +void CommandSyncProvider::abort() +{ + // NOTE: RemoteHandler does not expose an external abort mechanism. + // CommandSyncProvider sync operations cancel on the next polling boundary, + // not immediately. This is a CommandSyncProvider-specific limitation; + // network-based providers (Dropbox, Nextcloud) have proper abort via + // their QNetworkAccessManager. +} + +QString CommandSyncProvider::displayName() const +{ + return QStringLiteral("Command"); +} + +RemoteSyncParams* CommandSyncProvider::createParams() const +{ + return new CommandSyncParams(); +} diff --git a/src/remotesync/CommandSyncProvider.h b/src/remotesync/CommandSyncProvider.h new file mode 100644 index 0000000000..ab774c7db0 --- /dev/null +++ b/src/remotesync/CommandSyncProvider.h @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_COMMANDSYNCPROVIDER_H +#define KEEPASSXC_COMMANDSYNCPROVIDER_H + +#include "RemoteSyncProvider.h" + +#include + +class RemoteHandler; + +class CommandSyncProvider : public RemoteSyncProvider +{ + Q_OBJECT + +public: + explicit CommandSyncProvider(QObject* parent = nullptr); + ~CommandSyncProvider() override = default; + + RemoteHandler::RemoteResult download(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) override; + void abort() override; + + QString displayName() const override; + RemoteSyncParams* createParams() const override; + +private: + RemoteParams* toRemoteParams(const RemoteSyncParams* params) const; + + QScopedPointer m_handler; + Q_DISABLE_COPY(CommandSyncProvider) +}; + +#endif // KEEPASSXC_COMMANDSYNCPROVIDER_H diff --git a/src/remotesync/DropboxLoginFlow.cpp b/src/remotesync/DropboxLoginFlow.cpp new file mode 100644 index 0000000000..56b5221f01 --- /dev/null +++ b/src/remotesync/DropboxLoginFlow.cpp @@ -0,0 +1,410 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DropboxLoginFlow.h" + +#include "OAuthHttpServer.h" + +#include "core/Clock.h" +#include "crypto/CryptoHash.h" +#include "crypto/Random.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Localhost callback port. Dropbox PKCE flow's redirect_uri in the registered +// app is "http://localhost:12345"; the OAuthHttpServer listens on this fixed +// port. If the port is in use, the flow falls back to manual paste. +// --------------------------------------------------------------------------- +static constexpr quint16 kLocalCallbackPort = 12345; + +DropboxLoginFlow::DropboxLoginFlow(QObject* parent) + : QObject(parent) +{ + setObjectName(QStringLiteral("DropboxLoginFlow")); + + // Default browser opener: production calls QDesktopServices::openUrl. + // Tests inject a recorder via setBrowserOpener() before driving the flow. + m_browserOpener = [](const QUrl& url) { QDesktopServices::openUrl(url); }; + + // Timer exists from construction so the dtor cleans it up unconditionally. + m_timeoutTimer = new QTimer(this); + m_timeoutTimer->setSingleShot(true); + connect(m_timeoutTimer, &QTimer::timeout, this, &DropboxLoginFlow::onAuthTimeoutFired); +} + +DropboxLoginFlow::~DropboxLoginFlow() +{ + // Cancel in-flight flow before teardown. + cancel(); +} + +void DropboxLoginFlow::setNetworkAccessManager(QNetworkAccessManager* nam) +{ + if (m_nam && m_nam != nam) { + delete m_nam; + } + m_nam = nam; + if (m_nam && m_nam->parent() != this) { + m_nam->setParent(this); + } +} + +void DropboxLoginFlow::ensureNam() +{ + if (!m_nam) { + m_nam = new QNetworkAccessManager(this); + } +} + +void DropboxLoginFlow::setBrowserOpener(std::function opener) +{ + if (opener) { + m_browserOpener = std::move(opener); + } +} + +// --------------------------------------------------------------------------- +// PKCE helpers (RFC 7636). +// --------------------------------------------------------------------------- + +QString DropboxLoginFlow::generateCodeVerifier() +{ + // 32 random bytes -> base64url -> ~43 chars (within RFC 7636's 43-128 range) + QByteArray randomBytes = randomGen()->randomArray(32); + return QString::fromLatin1(randomBytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +QString DropboxLoginFlow::deriveCodeChallenge(const QString& codeVerifier) +{ + QByteArray hash = CryptoHash::hash(codeVerifier.toUtf8(), CryptoHash::Sha256); + return QString::fromLatin1(hash.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +// --------------------------------------------------------------------------- +// startAuthorization -- PKCE + browser handshake + localhost-callback / manual +// fallback decision. Fully signal-driven so the caller (DropboxCloudSyncPage) +// does not need a QPointer reentrancy guard. +// --------------------------------------------------------------------------- +void DropboxLoginFlow::startAuthorization(const QString& appKey, int timeoutMs) +{ + // Cancel-previous semantics mirror NextcloudLoginFlow::startLoginFlow. + if (m_state != State::Idle && m_state != State::Completed && m_state != State::Failed + && m_state != State::Cancelled) { + cancel(); + } + + if (appKey.isEmpty()) { + // Empty appKey is a caller wiring bug -- but emit a banner rather than + // assert because the page-side click handler should have already + // validated. Belt-and-suspenders. + emitFailureWithBanner(tr("App Key is required for authorization.")); + return; + } + + m_appKey = appKey; + m_timeoutMs = (timeoutMs > 0) ? timeoutMs : AuthTimeoutMs; + + // Generate PKCE code_verifier + code_challenge. + m_codeVerifier = generateCodeVerifier(); + const QString codeChallenge = deriveCodeChallenge(m_codeVerifier); + + // CSRF state parameter (RFC 6749 §10.12). Tied to the localhost server's + // expected-state validation below; only meaningful in the browser-callback + // branch. + QByteArray stateBytes = randomGen()->randomArray(16); + const QString oauthState = + QString::fromLatin1(stateBytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + + // Try to bind the localhost callback server. On failure, take the manual- + // paste fallback path -- the user copies the auth code from the browser + // and submits it via submitManualCode. + m_server = new OAuthHttpServer(this); + const bool serverUp = m_server->start(kLocalCallbackPort); + bool useCopyPaste = !serverUp; + + if (serverUp) { + m_server->setExpectedState(oauthState); + m_redirectUri = QStringLiteral("http://localhost:%1").arg(m_server->port()); + connect(m_server, &OAuthHttpServer::authCodeReceived, this, &DropboxLoginFlow::onServerAuthCode); + connect(m_server, &OAuthHttpServer::authError, this, &DropboxLoginFlow::onServerAuthError); + } else { + m_redirectUri.clear(); + delete m_server; + m_server = nullptr; + } + + // Build authorization URL. + QUrl authUrl(QStringLiteral("https://www.dropbox.com/oauth2/authorize")); + QUrlQuery authQuery; + authQuery.addQueryItem(QStringLiteral("client_id"), m_appKey); + authQuery.addQueryItem(QStringLiteral("response_type"), QStringLiteral("code")); + authQuery.addQueryItem(QStringLiteral("code_challenge"), codeChallenge); + authQuery.addQueryItem(QStringLiteral("code_challenge_method"), QStringLiteral("S256")); + authQuery.addQueryItem(QStringLiteral("token_access_type"), QStringLiteral("offline")); + authQuery.addQueryItem(QStringLiteral("state"), oauthState); + if (!useCopyPaste) { + authQuery.addQueryItem(QStringLiteral("redirect_uri"), m_redirectUri); + } + authUrl.setQuery(authQuery); + + // Open the browser. No qDebug printing of the URL -- no-secrets-in-logs. + m_browserOpener(authUrl); + + if (useCopyPaste) { + // Manual fallback: caller collects an auth code from the user and + // submits it via submitManualCode. The codeVerifier is part of the + // signal payload because the caller round-trips it back into + // submitManualCode -- this keeps DropboxLoginFlow stateful only across + // its own lifetime, not across page-side button clicks. + m_state = State::ManualFallback; + emit authorizationManualFallback(m_codeVerifier); + return; + } + + // Browser-callback branch: arm the auth timeout and wait for the server + // to emit authCodeReceived / authError. State stays Authorizing until one + // of those fires (or the timer expires, or cancel() fires). + m_state = State::Authorizing; + m_timeoutTimer->start(m_timeoutMs); +} + +// --------------------------------------------------------------------------- +// submitManualCode -- caller's response to authorizationManualFallback. +// --------------------------------------------------------------------------- +void DropboxLoginFlow::submitManualCode(const QString& authCode, int timeoutMs) +{ + if (m_state != State::ManualFallback) { + // Out-of-state submit is a caller wiring bug -- emit a banner rather + // than assert. Don't transition state. + emit authorizationFailed(tr("Authorization is not awaiting a manual code.")); + return; + } + + const QString code = authCode.trimmed(); + if (code.isEmpty()) { + emit authorizationFailed(tr("Authorization code is required")); + return; + } + if (m_codeVerifier.isEmpty()) { + emit authorizationFailed(tr("Code verifier is missing -- restart authorization")); + return; + } + if (m_appKey.isEmpty()) { + emit authorizationFailed(tr("App Key is required")); + return; + } + + const int effectiveTimeout = (timeoutMs > 0) ? timeoutMs : m_timeoutMs; + // Manual paste path: redirectUri MUST be empty -- the authorize URL had + // no redirect_uri parameter, and Dropbox rejects mismatched redirect_uri + // on token exchange. + exchangeAuthCode(code, QString(), effectiveTimeout); +} + +// --------------------------------------------------------------------------- +// onServerAuthCode -- localhost callback succeeded. Kick off token exchange. +// --------------------------------------------------------------------------- +void DropboxLoginFlow::onServerAuthCode(const QString& code) +{ + if (m_state != State::Authorizing) { + return; // late-arriving signal after cancel / timeout + } + // Disarm the auth timer; the exchange POST has its own implicit timeout + // via the QNetworkAccessManager call. + m_timeoutTimer->stop(); + if (m_server) { + m_server->stop(); + } + exchangeAuthCode(code, m_redirectUri, m_timeoutMs); +} + +void DropboxLoginFlow::onServerAuthError(const QString& error) +{ + if (m_state != State::Authorizing) { + return; + } + emitFailureWithBanner(tr("Authorization failed: %1").arg(error)); +} + +void DropboxLoginFlow::onAuthTimeoutFired() +{ + if (m_state != State::Authorizing) { + return; + } + emitFailureWithBanner(tr("Authorization timed out. Try again.")); +} + +// --------------------------------------------------------------------------- +// exchangeAuthCode -- async POST to /oauth2/token. No retries: the user just +// pasted a code, so a failed exchange means the user re-authorizes. +// --------------------------------------------------------------------------- +void DropboxLoginFlow::exchangeAuthCode(const QString& authCode, const QString& redirectUri, int /*timeoutMs*/) +{ + ensureNam(); + + QByteArray postBody; + postBody.append("code="); + postBody.append(QUrl::toPercentEncoding(authCode)); + postBody.append("&grant_type=authorization_code"); + postBody.append("&code_verifier="); + postBody.append(QUrl::toPercentEncoding(m_codeVerifier)); + postBody.append("&client_id="); + postBody.append(QUrl::toPercentEncoding(m_appKey)); + + // Only include redirect_uri if it was used in the authorize URL + // (Dropbox's PKCE token exchange rejects mismatched redirect_uri). + if (!redirectUri.isEmpty()) { + postBody.append("&redirect_uri="); + postBody.append(QUrl::toPercentEncoding(redirectUri)); + } + + QNetworkRequest request(QUrl(QStringLiteral("https://api.dropboxapi.com/oauth2/token"))); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); + + QNetworkReply* reply = m_nam->post(request, postBody); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + connect(reply, &QNetworkReply::finished, this, &DropboxLoginFlow::onExchangeFinished); + + // Zero the POST body (contains auth code + code verifier). The QNAM has + // already taken its own copy. + postBody.fill('\0'); + + m_state = State::Exchanging; +} + +void DropboxLoginFlow::onExchangeFinished() +{ + QNetworkReply* reply = nullptr; + { + QMutexLocker locker(&m_replyMutex); + reply = m_activeReply.data(); + m_activeReply.clear(); + } + if (!reply) { + // Already torn down (cancel fired before signal). Ignore. + return; + } + + if (m_state != State::Exchanging) { + // Late-arriving signal after cancel. Drain + release the reply but do + // not emit anything (cancel already emitted authorizationCancelled). + reply->readAll(); + reply->deleteLater(); + return; + } + + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + const QString errorMsg = tr("Token exchange failed: %1").arg(reply->errorString()); + reply->deleteLater(); + emitFailureWithBanner(errorMsg); + return; + } + + QByteArray responseData = reply->readAll(); + reply->deleteLater(); + + QJsonDocument respDoc = QJsonDocument::fromJson(responseData); + QJsonObject respObj = respDoc.object(); + + if (httpStatus != 200) { + // Do not log the response body -- it can contain sensitive OAuth + // fields. Status + length is enough for diagnostics. + qWarning("[DPX] exchangeAuthCode: error (status %d, body length %d)", httpStatus, responseData.length()); + const QString errorTag = respObj[QStringLiteral("error")].toString(); + const QString errorDesc = respObj[QStringLiteral("error_description")].toString(); + responseData.fill('\0'); + emitFailureWithBanner(tr("Token exchange failed: %1").arg(errorDesc.isEmpty() ? errorTag : errorDesc)); + return; + } + + // Parse successful response + const QString accessToken = respObj[QStringLiteral("access_token")].toString(); + const QString refreshToken = respObj[QStringLiteral("refresh_token")].toString(); + const int expiresIn = respObj[QStringLiteral("expires_in")].toInt(); + + if (accessToken.isEmpty() || refreshToken.isEmpty()) { + responseData.fill('\0'); + emitFailureWithBanner(tr("Token exchange failed: missing tokens in response")); + return; + } + + const QDateTime expiresAt = Clock::currentDateTimeUtc().addSecs(expiresIn); + responseData.fill('\0'); + + teardown(); + m_state = State::Completed; + emit authorizationCompleted(accessToken, refreshToken, expiresAt.toMSecsSinceEpoch()); +} + +// --------------------------------------------------------------------------- +// cancel -- abort any in-flight flow. Emits authorizationCancelled exactly +// once if a non-terminal flow was stopped. +// --------------------------------------------------------------------------- +void DropboxLoginFlow::cancel() +{ + const bool wasActive = (m_state == State::Authorizing || m_state == State::ManualFallback + || m_state == State::Exchanging); + + // Abort active reply (under mutex; marshal to network thread). + { + QMutexLocker locker(&m_replyMutex); + if (m_activeReply) { + QMetaObject::invokeMethod(m_activeReply.data(), "abort", Qt::QueuedConnection); + } + } + + teardown(); + + if (wasActive) { + m_state = State::Cancelled; + emit authorizationCancelled(); + } +} + +void DropboxLoginFlow::teardown() +{ + if (m_timeoutTimer) { + m_timeoutTimer->stop(); + } + if (m_server) { + m_server->stop(); + m_server->deleteLater(); + m_server = nullptr; + } +} + +void DropboxLoginFlow::emitFailureWithBanner(const QString& bannerText) +{ + teardown(); + m_state = State::Failed; + emit authorizationFailed(bannerText); +} diff --git a/src/remotesync/DropboxLoginFlow.h b/src/remotesync/DropboxLoginFlow.h new file mode 100644 index 0000000000..b63e827a36 --- /dev/null +++ b/src/remotesync/DropboxLoginFlow.h @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_DROPBOXLOGINFLOW_H +#define KEEPASSXC_DROPBOXLOGINFLOW_H + +#include +#include +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QNetworkReply; +class OAuthHttpServer; + +/** + * Dropbox OAuth2 + PKCE login flow driver: PKCE generation + browser handshake + * + localhost callback OR manual paste fallback + authorization-code exchange. + * + * Self-contained auth class owning its transport and exposing terminal + * signals, so DropboxSyncProvider stays focused on download / upload / + * refreshAuth. + */ +class DropboxLoginFlow : public QObject +{ + Q_OBJECT + +public: + explicit DropboxLoginFlow(QObject* parent = nullptr); + ~DropboxLoginFlow() override; + + // Begin a PKCE OAuth2 handshake: generate code_verifier + state, start the + // localhost callback server (or fall back to manual paste), build the + // authorize URL, open it in the user's browser, and arm the auth timeout. + // Cancels any previously in-flight flow first. + // + // If the localhost server fails to start (port conflict, sandboxed + // environment, etc.), emits authorizationManualFallback(codeVerifier) + // synchronously and enters ManualFallback state -- the caller is expected + // to collect an auth code from the user and submit it via submitManualCode. + // + // Virtual so MockDropboxLoginFlow can override for page-level UI tests + // (avoids exercising real PKCE / browser-open / OAuthHttpServer in unit + // tests). + virtual void startAuthorization(const QString& appKey, int timeoutMs); + + // Exchange a manually-pasted authorization code for tokens. Used after + // authorizationManualFallback was emitted -- redirect_uri is intentionally + // empty per Dropbox's PKCE rule that the token exchange's redirect_uri + // must match the authorize URL's (which, in manual fallback, had none). + virtual void submitManualCode(const QString& authCode, int timeoutMs); + + // Cancel any in-flight flow and emit authorizationCancelled. Safe to call + // from any thread; reply abort is marshalled to the network thread via + // QueuedConnection. Idempotent on terminal / Idle state. + virtual void cancel(); + + // Test seam -- production lazy-constructs the default. Caller retains + // ownership of the injected NAM. + void setNetworkAccessManager(QNetworkAccessManager* nam); + + // Test seam -- production uses QDesktopServices::openUrl. Empty + // std::function is ignored. + void setBrowserOpener(std::function opener); + + // Production browser-auth timeout: 2 minutes. + static constexpr int AuthTimeoutMs = 120000; + + // PKCE helpers (public static -- pure functions, no side effects, needed + // for testing). + static QString generateCodeVerifier(); + static QString deriveCodeChallenge(const QString& codeVerifier); + +signals: + // Emitted exactly once when the localhost callback server fails to start + // and the caller must collect a pasted auth code from the user. The + // codeVerifier payload is the PKCE verifier the caller has to round-trip + // back via submitManualCode. + void authorizationManualFallback(QString codeVerifier); + + // Emitted on successful token exchange -- payload is the credentials the + // caller persists. expiresAtMs is QDateTime::toMSecsSinceEpoch on the + // computed expiry (Clock::currentDateTimeUtc + expires_in). + void authorizationCompleted(QString accessToken, QString refreshToken, qint64 expiresAtMs); + + // Emitted on hard failure (browser-auth timeout, server-side OAuth error, + // exchange-POST network/HTTP failure) -- carries the user-facing banner + // verbatim. + void authorizationFailed(QString reason); + + // Emitted exactly once when cancel() succeeds in stopping a non-terminal + // flow. + void authorizationCancelled(); + +private slots: + // OAuthHttpServer::authCodeReceived -- kicks off the token exchange. + void onServerAuthCode(const QString& code); + + // OAuthHttpServer::authError -- emits authorizationFailed. + void onServerAuthError(const QString& error); + + // m_timeoutTimer fired -- emits authorizationFailed and tears down. + void onAuthTimeoutFired(); + + // QNetworkReply::finished on the token-exchange POST -- parses tokens, + // emits authorizationCompleted or authorizationFailed. + void onExchangeFinished(); + +private: + // State machine for the login-flow lifecycle. + enum class State + { + Idle, + Authorizing, // server listening, waiting for browser callback + ManualFallback, // server failed to start, waiting for submitManualCode + Exchanging, // token-exchange POST in flight + Completed, + Failed, + Cancelled + }; + + // Lazy-construct the QNetworkAccessManager, or use the injected one. + void ensureNam(); + + // Internal exchange entry. Builds the application/x-www-form-urlencoded + // body, posts to /oauth2/token, wires onExchangeFinished. redirectUri is + // empty for manual paste, set to http://localhost: for the browser + // callback path. + void exchangeAuthCode(const QString& authCode, const QString& redirectUri, int timeoutMs); + + // Tear down server + timer + active reply under mutex. Called from + // terminal transitions. Idempotent. + void teardown(); + + // DRY emit helper: transition to Failed and emit authorizationFailed. + void emitFailureWithBanner(const QString& bannerText); + + QNetworkAccessManager* m_nam = nullptr; + OAuthHttpServer* m_server = nullptr; + QTimer* m_timeoutTimer = nullptr; + + std::function m_browserOpener; + + QString m_appKey; + QString m_codeVerifier; + QString m_redirectUri; // empty in ManualFallback, "http://localhost:" otherwise + int m_timeoutMs = AuthTimeoutMs; + + QPointer m_activeReply; + mutable QMutex m_replyMutex; + + State m_state = State::Idle; + + Q_DISABLE_COPY(DropboxLoginFlow) +}; + +#endif // KEEPASSXC_DROPBOXLOGINFLOW_H diff --git a/src/remotesync/DropboxSyncProvider.cpp b/src/remotesync/DropboxSyncProvider.cpp new file mode 100644 index 0000000000..06d4ad6b3f --- /dev/null +++ b/src/remotesync/DropboxSyncProvider.cpp @@ -0,0 +1,682 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DropboxSyncProvider.h" + +#include "HttpRetryHelper.h" +#include "RemoteSyncParams.h" + +#include "core/Clock.h" +#include "gui/remote/RemoteSettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DropboxSyncProvider::DropboxSyncProvider(QObject* parent) + : RemoteSyncProvider(parent) +{ + m_abortFlag.storeRelease(0); +} + +DropboxSyncProvider::~DropboxSyncProvider() = default; + +void DropboxSyncProvider::setNetworkAccessManager(QNetworkAccessManager* nam) +{ + // Caller retains ownership of the injected NAM (see header doc). Do not + // delete or reparent -- both would violate the contract and risk + // double-free / use-after-free if the caller owns the mock on its stack + // or swaps it for another instance. Internal NAMs created via + // `new QNetworkAccessManager(this)` in ensureNam() are still cleaned up + // by Qt parent-child on destruction of this object. + m_nam = nam; +} + +void DropboxSyncProvider::ensureNam() +{ + if (!m_nam) { + m_nam = new QNetworkAccessManager(this); + } +} + +RemoteHandler::RemoteResult DropboxSyncProvider::download(const RemoteSyncParams* params) +{ + // Safe: the factory and buildParamsFromConfig pair providers and params + // by type, so a DropboxSyncProvider only ever receives DropboxSyncParams. + // Same applies to upload / refreshAuth / applyRefreshedTokens below. + auto* dpxParams = static_cast(params); + + // Validate remote path + if (!dpxParams->remotePath.startsWith(QLatin1Char('/'))) { + return {false, tr("Remote path must start with '/'"), {}, {}, {}}; + } + + // Lazy-init QNAM if not injected (own instance for clean thread affinity) + ensureNam(); + + // Clear stale rev so a failed download doesn't leave an outdated rev for upload + m_lastRev.clear(); + + m_abortFlag.storeRelease(0); + + const int timeoutMs = dpxParams->timeoutMsec; + QByteArray authHeader = QByteArray("Bearer ") + dpxParams->accessToken.toUtf8(); + + // Build Dropbox-API-Arg header JSON (QJsonDocument handles non-ASCII path encoding) + QJsonObject apiArg; + apiArg[QStringLiteral("path")] = dpxParams->remotePath; + const QByteArray apiArgJson = QJsonDocument(apiArg).toJson(QJsonDocument::Compact); + + // Capture QNAM pointer for the lambda (m_nam is stable across the call) + QNetworkAccessManager* nam = m_nam; + + auto makeRequest = [this, nam, &authHeader, &apiArgJson]() -> QNetworkReply* { + QNetworkRequest request(QUrl(QStringLiteral("https://content.dropboxapi.com/2/files/download"))); + request.setRawHeader("Authorization", authHeader); + request.setRawHeader("Dropbox-API-Arg", apiArgJson); + // Do NOT set Content-Type -- Dropbox /2/files/download rejects + // requests that include it. + + QNetworkReply* reply = nam->post(request, QByteArray()); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, timeoutMs, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + + // Zero the auth header now that all requests are complete + authHeader.fill('\0'); + + if (!reply) { + return {false, tr("Network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + // Pure network errors (no HTTP status received at all). + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + QString errorMsg = tr("Network error: %1").arg(reply->errorString()); + qWarning("[DPX] download: network error: %s", qPrintable(errorMsg)); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}, ErrorKind::Network}; + } + + if (httpStatus == HttpConflict) { + // Endpoint-specific error -- parse error_summary + QByteArray body = reply->readAll(); + QJsonDocument errDoc = QJsonDocument::fromJson(body); + QString errorSummary = errDoc.object()[QStringLiteral("error_summary")].toString(); + reply->deleteLater(); + + if (errorSummary.startsWith(QStringLiteral("path/not_found"))) { + // File doesn't exist on remote -- not an error for first sync + return {true, {}, {}, {}, {}}; + } + + return {false, tr("Dropbox error: %1").arg(errorSummary), {}, {}, {}, ErrorKind::NotFound}; + } + + if (httpStatus != HttpOk) { + reply->readAll(); // drain body to free network resources + reply->deleteLater(); + // Map a few key statuses to ErrorKind; others stay Other. + ErrorKind kind = ErrorKind::Other; + if (httpStatus == 401) { + kind = ErrorKind::AuthExpired; + } else if (httpStatus == 403) { + kind = ErrorKind::Permission; + } else if (httpStatus == 429) { + kind = ErrorKind::RateLimit; + } else if (httpStatus >= 500 && httpStatus < 600) { + kind = ErrorKind::ServerError; + } + return {false, tr("Dropbox API error (HTTP %1)").arg(httpStatus), {}, {}, {}, kind}; + } + + // Success (HTTP 200) -- extract rev from Dropbox-API-Result header + QByteArray resultHeader = reply->rawHeader("Dropbox-API-Result"); + QJsonDocument metaDoc = QJsonDocument::fromJson(resultHeader); + if (!metaDoc.isNull() && metaDoc.isObject()) { + m_lastRev = metaDoc.object()[QStringLiteral("rev")].toString(); + } + + // Check Content-Length before reading to avoid OOM on malicious responses + qint64 contentLength = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + if (contentLength > MaxDatabaseSize) { + reply->deleteLater(); + return {false, tr("Downloaded file exceeds size limit (%1 bytes)").arg(contentLength), {}, {}, {}}; + } + + QByteArray fileData = reply->readAll(); + reply->deleteLater(); + + // Trust boundary: also check actual size (Content-Length may be absent or wrong). + if (fileData.size() > MaxDatabaseSize) { + return {false, tr("Downloaded file exceeds size limit (%1 bytes)").arg(fileData.size()), {}, {}, {}}; + } + + // Write file content to a temporary file + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(false); + if (!tmpFile.open()) { + return {false, tr("Failed to create temporary file"), {}, {}, {}}; + } + + if (tmpFile.write(fileData) != fileData.size()) { + tmpFile.remove(); + return {false, tr("Failed to write temporary file"), {}, {}, {}}; + } + tmpFile.close(); + + return {true, {}, tmpFile.fileName(), {}, {}}; +} + +RemoteHandler::RemoteResult DropboxSyncProvider::upload(const QString& filePath, const RemoteSyncParams* params) +{ + auto* dpxParams = static_cast(params); + + // Validate remote path + if (!dpxParams->remotePath.startsWith(QLatin1Char('/'))) { + return {false, tr("Remote path must start with '/'"), {}, {}, {}}; + } + + // Read file content + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + return {false, tr("Failed to open file for upload: %1").arg(filePath), {}, {}, {}}; + } + QByteArray fileData = file.readAll(); + file.close(); + + if (fileData.size() > MaxDatabaseSize) { + return {false, tr("File exceeds size limit (%1 bytes)").arg(fileData.size()), {}, {}, {}}; + } + + // Lazy-init QNAM if not injected + ensureNam(); + + // Reset abort flag + m_abortFlag.storeRelease(0); + + const int timeoutMs = dpxParams->timeoutMsec; + QByteArray authHeader = QByteArray("Bearer ") + dpxParams->accessToken.toUtf8(); + + // Build Dropbox-API-Arg header JSON + QJsonObject apiArg; + apiArg[QStringLiteral("path")] = dpxParams->remotePath; + + if (m_lastRev.isEmpty()) { + // First upload (file didn't exist on remote) -- use "add" mode + apiArg[QStringLiteral("mode")] = QStringLiteral("add"); + } else { + // Update existing file -- "update" mode must be an object with .tag + // and update fields; the string shorthand other modes use is rejected. + QJsonObject mode; + mode[QStringLiteral(".tag")] = QStringLiteral("update"); + mode[QStringLiteral("update")] = m_lastRev; + apiArg[QStringLiteral("mode")] = mode; + } + + apiArg[QStringLiteral("autorename")] = false; // Do NOT create conflicted copies + apiArg[QStringLiteral("mute")] = true; // Suppress Dropbox desktop notifications + + const QByteArray apiArgJson = QJsonDocument(apiArg).toJson(QJsonDocument::Compact); + + QNetworkAccessManager* nam = m_nam; + + auto makeRequest = [this, nam, &authHeader, &apiArgJson, &fileData]() -> QNetworkReply* { + QNetworkRequest request(QUrl(QStringLiteral("https://content.dropboxapi.com/2/files/upload"))); + request.setRawHeader("Authorization", authHeader); + request.setRawHeader("Dropbox-API-Arg", apiArgJson); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/octet-stream")); + + QNetworkReply* reply = nam->post(request, fileData); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, timeoutMs, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + + // Zero the auth header now that all requests are complete + authHeader.fill('\0'); + + if (!reply) { + return {false, tr("Network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + // Pure network errors (no HTTP status received at all). + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + QString errorMsg = tr("Network error: %1").arg(reply->errorString()); + qWarning("[DPX] upload: network error: %s", qPrintable(errorMsg)); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}, ErrorKind::Network}; + } + + if (httpStatus == HttpConflict) { + // Endpoint-specific error + QByteArray body = reply->readAll(); + QJsonDocument errDoc = QJsonDocument::fromJson(body); + QString errorSummary = errDoc.object()[QStringLiteral("error_summary")].toString(); + reply->deleteLater(); + + if (errorSummary.startsWith(QStringLiteral("path/conflict"))) { + return {false, + tr("Remote file changed since last download. Re-sync to merge changes."), + {}, + {}, + {}, + ErrorKind::Conflict}; + } + + return {false, tr("Dropbox error: %1").arg(errorSummary), {}, {}, {}, ErrorKind::Other}; + } + + if (httpStatus != HttpOk) { + reply->readAll(); // drain body to free network resources + reply->deleteLater(); + ErrorKind kind = ErrorKind::Other; + if (httpStatus == 401) { + kind = ErrorKind::AuthExpired; + } else if (httpStatus == 403) { + kind = ErrorKind::Permission; + } else if (httpStatus == 429) { + kind = ErrorKind::RateLimit; + } else if (httpStatus == 507) { + kind = ErrorKind::Quota; + } else if (httpStatus >= 500 && httpStatus < 600) { + kind = ErrorKind::ServerError; + } + return {false, tr("Dropbox API error (HTTP %1)").arg(httpStatus), {}, {}, {}, kind}; + } + + // Success -- update m_lastRev from response body so subsequent uploads + // in the same session use the rev the server now holds. + QJsonDocument respDoc = QJsonDocument::fromJson(reply->readAll()); + if (!respDoc.isNull() && respDoc.isObject()) { + QString newRev = respDoc.object()[QStringLiteral("rev")].toString(); + if (!newRev.isEmpty()) { + m_lastRev = newRev; + } + } + + reply->deleteLater(); + return {true, {}, {}, {}, {}}; +} + +// --------------------------------------------------------------------------- +// refreshAuth -- proactive token refresh via Dropbox /oauth2/token +// --------------------------------------------------------------------------- + +RemoteHandler::RemoteResult DropboxSyncProvider::refreshAuth(const RemoteSyncParams* params) +{ + auto* dpxParams = static_cast(params); + + if (dpxParams->refreshToken.isEmpty()) { + return {false, tr("No refresh token. Re-authorize in Settings."), {}, {}, {}, ErrorKind::AuthRevoked}; + } + + // Proactive check: if token still valid with buffer, skip refresh + if (dpxParams->expiresAt.isValid() + && Clock::currentDateTimeUtc().addSecs(TokenRefreshBufferSecs) < dpxParams->expiresAt) { + return {true, {}, {}, {}, {}}; + } + + ensureNam(); + m_abortFlag.storeRelease(0); + + const int timeoutMs = dpxParams->timeoutMsec; + + // Build POST body for refresh_token grant + QByteArray postBody; + postBody.append("grant_type=refresh_token"); + postBody.append("&refresh_token="); + postBody.append(QUrl::toPercentEncoding(dpxParams->refreshToken)); + postBody.append("&client_id="); + postBody.append(QUrl::toPercentEncoding(dpxParams->appKey)); + + QNetworkAccessManager* nam = m_nam; + + auto makeRequest = [this, nam, &postBody]() -> QNetworkReply* { + QNetworkRequest request(QUrl(QStringLiteral("https://api.dropboxapi.com/oauth2/token"))); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); + + QNetworkReply* reply = nam->post(request, postBody); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, timeoutMs, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + + // Zero the POST body (contains refresh token) + postBody.fill('\0'); + + if (!reply) { + return {false, tr("Token refresh failed: network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + QString errorMsg = tr("Token refresh failed: %1").arg(reply->errorString()); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}, ErrorKind::Network}; + } + + QByteArray responseData = reply->readAll(); + reply->deleteLater(); + + QJsonDocument respDoc = QJsonDocument::fromJson(responseData); + QJsonObject respObj = respDoc.object(); + + if (httpStatus != HttpOk) { + // Do not log the response body -- it can contain sensitive OAuth + // fields. Status code + length is enough for diagnostics. + qWarning("[DPX] refreshAuth: error (status %d, body length %d)", httpStatus, responseData.length()); + // Check for invalid_grant (refresh token revoked/expired) + QString errorTag = respObj[QStringLiteral("error")].toString(); + if (errorTag == QStringLiteral("invalid_grant")) { + return {false, + tr("Refresh token expired or revoked. Re-authorize in Settings."), + {}, + {}, + {}, + ErrorKind::AuthRevoked}; + } + QString errorDesc = respObj[QStringLiteral("error_description")].toString(); + return {false, + tr("Token refresh failed: %1").arg(errorDesc.isEmpty() ? errorTag : errorDesc), + {}, + {}, + {}, + ErrorKind::AuthExpired}; + } + + // Parse successful response + QString newAccessToken = respObj[QStringLiteral("access_token")].toString(); + int expiresIn = respObj[QStringLiteral("expires_in")].toInt(); + + if (newAccessToken.isEmpty()) { + return {false, tr("Token refresh failed: missing access_token in response"), {}, {}, {}}; + } + + // Compute new expiry time + QDateTime newExpiresAt = Clock::currentDateTimeUtc().addSecs(expiresIn); + + // CRITICAL: Do NOT read refresh_token from response -- Dropbox does not + // return it on refresh. Keep existing refreshToken unchanged. + + // Build JSON output for caller to persist + QJsonObject tokenData; + tokenData[QStringLiteral("accessToken")] = newAccessToken; + tokenData[QStringLiteral("expiresAt")] = newExpiresAt.toMSecsSinceEpoch(); + QString tokenJson = QString::fromUtf8(QJsonDocument(tokenData).toJson(QJsonDocument::Compact)); + + // Zero sensitive data + responseData.fill('\0'); + + return {true, {}, {}, tokenJson, {}}; +} + +// --------------------------------------------------------------------------- +// revokeToken -- best-effort token revocation +// --------------------------------------------------------------------------- + +RemoteHandler::RemoteResult DropboxSyncProvider::revokeToken(const DropboxSyncParams* params) +{ + // Internal wiring: callers always pass a constructed DropboxSyncParams; + // a null here is a wiring bug rather than a user-facing failure. + Q_ASSERT(params); + + if (params->accessToken.isEmpty()) { + return {true, {}, {}, {}, {}}; + } + + ensureNam(); + m_abortFlag.storeRelease(0); + + QByteArray authHeader = QByteArray("Bearer ") + params->accessToken.toUtf8(); + QNetworkAccessManager* nam = m_nam; + + auto makeRequest = [this, nam, &authHeader]() -> QNetworkReply* { + QNetworkRequest request(QUrl(QStringLiteral("https://api.dropboxapi.com/2/auth/token/revoke"))); + request.setRawHeader("Authorization", authHeader); + // Revoke endpoint requires no body, but POST must have content-type + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + + QNetworkReply* reply = nam->post(request, QByteArray()); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + return reply; + }; + + // Short timeout (10s) for revocation -- best-effort + RetryPolicy policy; + policy.maxRetries = 1; // Don't retry aggressively for revocation + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 10000, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + + // Zero the auth header + authHeader.fill('\0'); + + if (reply) { + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus != HttpOk) { + qWarning("DropboxSyncProvider: token revocation returned HTTP %d (best-effort, ignoring)", httpStatus); + } + reply->deleteLater(); + } else { + qWarning("DropboxSyncProvider: token revocation network request failed (best-effort, ignoring)"); + } + + // Best-effort: always return success. Caller clears local tokens regardless. + return {true, {}, {}, {}, {}}; +} + +void DropboxSyncProvider::abort() +{ + m_abortFlag.storeRelease(1); + + QMutexLocker locker(&m_replyMutex); + if (m_activeReply) { + // Marshal abort() to the reply's owning thread for thread safety. + // Cancelling an in-progress browser-auth flow is the page-side's + // responsibility via DropboxLoginFlow::cancel. + QMetaObject::invokeMethod(m_activeReply, "abort", Qt::QueuedConnection); + } +} + +// --------------------------------------------------------------------------- +// RemoteSyncProvider abstraction overrides. +// --------------------------------------------------------------------------- + +QString DropboxSyncProvider::displayName() const +{ + // Untranslated identifier; UI applies tr() at call site. + return QStringLiteral("Dropbox"); +} + +RemoteSyncParams* DropboxSyncProvider::createParams() const +{ + // Caller takes ownership. + auto* params = new DropboxSyncParams(); + params->type = QStringLiteral("dropbox"); + return params; +} + +RemoteSyncParams* DropboxSyncProvider::buildParamsFromConfig(const QJsonObject& config) const +{ + // Caller takes ownership. + auto* params = new DropboxSyncParams(); + params->type = QStringLiteral("dropbox"); + params->name = config[QStringLiteral("name")].toString(); + params->appKey = config[QStringLiteral("appKey")].toString(); + params->remotePath = config[QStringLiteral("remotePath")].toString(); + params->accessToken = config[QStringLiteral("accessToken")].toString(); + params->refreshToken = config[QStringLiteral("refreshToken")].toString(); + if (config.contains(QStringLiteral("expiresAt"))) { + params->expiresAt = + QDateTime::fromMSecsSinceEpoch(config[QStringLiteral("expiresAt")].toVariant().toLongLong()); + } + return params; +} + +bool DropboxSyncProvider::applyRefreshedTokens(const QString& stdOutput, RemoteSyncParams* params) +{ + // Empty stdOutput means refreshAuth had no token data to apply (e.g. token + // still valid; proactive refresh skipped). Treat as success no-op. + if (stdOutput.isEmpty()) { + return true; + } + + QJsonDocument doc = QJsonDocument::fromJson(stdOutput.toUtf8()); + if (doc.isNull() || !doc.isObject()) { + // Hard-fail; engine surfaces auth-failure banner and the user + // re-authorizes. + qWarning("DropboxSyncProvider: failed to parse refreshed token JSON"); + return false; + } + + auto* dpxParams = static_cast(params); + + QJsonObject tokenData = doc.object(); + if (tokenData.contains(QStringLiteral("accessToken"))) { + dpxParams->accessToken = tokenData[QStringLiteral("accessToken")].toString(); + } + if (tokenData.contains(QStringLiteral("expiresAt"))) { + dpxParams->expiresAt = + QDateTime::fromMSecsSinceEpoch(tokenData[QStringLiteral("expiresAt")].toVariant().toLongLong()); + } + return true; +} + +RemoteSyncProvider::ErrorKind DropboxSyncProvider::classifyError(const QString& errorMessage) const +{ + // invalid_grant maps to AuthRevoked (refresh token revoked or expired). + // The other two map to AuthExpired (short-lived access-token expiry, + // recoverable via refresh_token grant). + if (errorMessage.contains(QStringLiteral("invalid_access_token"), Qt::CaseInsensitive) + || errorMessage.contains(QStringLiteral("expired_access_token"), Qt::CaseInsensitive)) { + return ErrorKind::AuthExpired; + } + if (errorMessage.contains(QStringLiteral("invalid_grant"), Qt::CaseInsensitive)) { + return ErrorKind::AuthRevoked; + } + return ErrorKind::Other; +} + +bool DropboxSyncProvider::isAuthorized(const QJsonObject& config) const +{ + // Operational contract: a config is "authorized" only if it has every + // field required for a successful sync round-trip. + // - accessToken: short-lived bearer used by download/upload + // - refreshToken: required by refreshAuth to mint a new accessToken + // (without it an expired/restarted session can never recover) + // - appKey: client_id used by refreshAuth + // - remotePath: target path on Dropbox; sync has no usable default + return !config.value(QStringLiteral("accessToken")).toString().isEmpty() + && !config.value(QStringLiteral("refreshToken")).toString().isEmpty() + && !config.value(QStringLiteral("appKey")).toString().isEmpty() + && !config.value(QStringLiteral("remotePath")).toString().isEmpty(); +} + +void DropboxSyncProvider::persistRefreshedTokens(const QString& stdOutput, + const QString& configKey, + RemoteSettings* settings) const +{ + // Persists refreshed tokens via the generic + // getProviderConfig/setProviderConfig API. + if (!settings || configKey.isEmpty()) { + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(stdOutput.toUtf8()); + if (doc.isNull() || !doc.isObject()) { + qWarning("DropboxSyncProvider: failed to parse refreshed token JSON for persist"); + return; + } + + QJsonObject tokenData = doc.object(); + QJsonObject config = settings->getProviderConfig(QStringLiteral("dropbox"), configKey); + if (config.isEmpty()) { + qWarning("DropboxSyncProvider: no Dropbox config found for '%s' to update tokens", qPrintable(configKey)); + return; + } + + // Update only the fields that refreshAuth returns (accessToken, expiresAt). + // Do NOT overwrite refreshToken -- Dropbox refresh response has no refresh_token field. + if (tokenData.contains(QStringLiteral("accessToken"))) { + config[QStringLiteral("accessToken")] = tokenData[QStringLiteral("accessToken")].toString(); + } + if (tokenData.contains(QStringLiteral("expiresAt"))) { + config[QStringLiteral("expiresAt")] = tokenData[QStringLiteral("expiresAt")]; + } + + settings->setProviderConfig(QStringLiteral("dropbox"), configKey, config); + settings->saveSettings(); +} diff --git a/src/remotesync/DropboxSyncProvider.h b/src/remotesync/DropboxSyncProvider.h new file mode 100644 index 0000000000..ede4ef6176 --- /dev/null +++ b/src/remotesync/DropboxSyncProvider.h @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_DROPBOXSYNCPROVIDER_H +#define KEEPASSXC_DROPBOXSYNCPROVIDER_H + +#include "RemoteSyncProvider.h" + +#include +#include + +class QJsonObject; +class QNetworkAccessManager; +class QNetworkReply; +struct DropboxSyncParams; + +class DropboxSyncProvider : public RemoteSyncProvider +{ + Q_OBJECT + +public: + explicit DropboxSyncProvider(QObject* parent = nullptr); + ~DropboxSyncProvider() override; + + RemoteHandler::RemoteResult download(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) override; + void abort() override; + + // RemoteSyncProvider abstraction overrides + QString displayName() const override; + RemoteSyncParams* createParams() const override; + RemoteSyncParams* buildParamsFromConfig(const QJsonObject& config) const override; + bool applyRefreshedTokens(const QString& stdOutput, RemoteSyncParams* params) override; + ErrorKind classifyError(const QString& errorMessage) const override; + bool isAuthorized(const QJsonObject& config) const override; + void + persistRefreshedTokens(const QString& stdOutput, const QString& configKey, RemoteSettings* settings) const override; + + // Revoke tokens with Dropbox. Best-effort: always returns success + // (local cleanup is caller's responsibility regardless of revocation outcome). + // Stays on the provider rather than DropboxLoginFlow because it is a + // post-session token operation, not part of the login state machine. + virtual RemoteHandler::RemoteResult revokeToken(const DropboxSyncParams* params); + + // Inject a QNetworkAccessManager for testing (mock QNAM whose post() + // returns MockNetworkReply). If set, this QNAM is used instead of + // creating our own. Caller retains ownership: the injected NAM must + // outlive this object, is never delete-d or reparented by the setter, + // and calling with nullptr does not free a previously-set NAM. + void setNetworkAccessManager(QNetworkAccessManager* nam); + + Q_DISABLE_COPY(DropboxSyncProvider) + +private: + // Lazy-construct the QNetworkAccessManager unless one was injected. + void ensureNam(); + + QNetworkAccessManager* m_nam = nullptr; + + static constexpr int HttpOk = 200; + static constexpr int HttpConflict = 409; + static constexpr int MaxDatabaseSize = 256 * 1024 * 1024; // 256 MB sanity limit + static constexpr int TokenRefreshBufferSecs = 600; // 10-minute proactive refresh + + QString m_lastRev; // Rev from last download, used for upload mode:update + QNetworkReply* m_activeReply = nullptr; // For abort support + mutable QMutex m_replyMutex; // Protects m_activeReply across threads + QAtomicInt m_abortFlag; // Atomic flag checked by HttpRetryHelper between retries +}; + +#endif // KEEPASSXC_DROPBOXSYNCPROVIDER_H diff --git a/src/remotesync/HttpRetryHelper.cpp b/src/remotesync/HttpRetryHelper.cpp new file mode 100644 index 0000000000..80b08f1eea --- /dev/null +++ b/src/remotesync/HttpRetryHelper.cpp @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "HttpRetryHelper.h" + +#include +#include +#include + +QNetworkReply* HttpRetryHelper::execute(const RequestFunc& makeRequest, + const RetryPolicy& policy, + int timeoutMs, + QAtomicInt* abortFlag) +{ + QNetworkReply* reply = nullptr; + + for (int attempt = 0; attempt <= policy.maxRetries; ++attempt) { + // Check abort flag before each attempt + if (abortFlag && abortFlag->loadAcquire() != 0) { + // Return whatever reply we have (or nullptr on first attempt) + return reply; + } + + // Clean up previous reply if retrying + if (reply) { + reply->deleteLater(); + reply = nullptr; + } + + reply = makeRequest(); + if (!reply) { + return nullptr; + } + + // Wait for reply to finish or timeout + { + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + + timer.start(timeoutMs); + if (!reply->isFinished()) { + loop.exec(); + } + + if (!reply->isFinished()) { + reply->abort(); + // Return the aborted reply -- caller sees the error + return reply; + } + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // If not retryable or this was our last attempt, return as-is + if (!isRetryable(httpStatus) || attempt == policy.maxRetries) { + return reply; + } + + // Determine delay before retry (use qint64 to prevent integer overflow) + int shift = qMin(attempt, 20); + qint64 baseDelay = static_cast(policy.baseDelayMs) * (1 << shift); + // Add jitter: multiply by random factor in [0.5, 1.0) to avoid thundering herd + double jitter = 0.5 + QRandomGenerator::global()->generateDouble() * 0.5; + int delayMs = static_cast(qMin(static_cast(baseDelay * jitter), static_cast(INT_MAX))); + + // Check for Retry-After header (seconds) + if (reply->hasRawHeader("Retry-After")) { + bool ok = false; + int retryAfterSec = reply->rawHeader("Retry-After").toInt(&ok); + if (ok && retryAfterSec > 0) { + // Fail immediately if Retry-After exceeds our cap + if (retryAfterSec > policy.maxRetryAfterSec) { + return reply; + } + int retryAfterMs = + static_cast(qMin(static_cast(retryAfterSec) * 1000, static_cast(INT_MAX))); + if (retryAfterMs > delayMs) { + delayMs = retryAfterMs; + } + } + } + + // Wait for the delay, checking abort flag periodically + { + QEventLoop delayLoop; + QTimer delayTimer; + delayTimer.setSingleShot(true); + QObject::connect(&delayTimer, &QTimer::timeout, &delayLoop, &QEventLoop::quit); + + // Poll abort flag every 100ms so abort() is responsive during long delays + QTimer abortPollTimer; + if (abortFlag) { + abortPollTimer.setInterval(100); + QObject::connect(&abortPollTimer, &QTimer::timeout, [&]() { + if (abortFlag->loadAcquire() != 0) { + delayLoop.quit(); + } + }); + abortPollTimer.start(); + } + + delayTimer.start(delayMs); + delayLoop.exec(); + } + + // Check abort flag after delay + if (abortFlag && abortFlag->loadAcquire() != 0) { + return reply; + } + } + + return reply; +} + +bool HttpRetryHelper::isRetryable(int httpStatus) +{ + return httpStatus == 429 || (httpStatus >= 500 && httpStatus <= 599); +} diff --git a/src/remotesync/HttpRetryHelper.h b/src/remotesync/HttpRetryHelper.h new file mode 100644 index 0000000000..68a508307c --- /dev/null +++ b/src/remotesync/HttpRetryHelper.h @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_HTTPRETRYHELPER_H +#define KEEPASSXC_HTTPRETRYHELPER_H + +#include +#include +#include + +struct RetryPolicy +{ + int maxRetries = 3; + int baseDelayMs = 1000; // Exponential backoff with jitter + int maxRetryAfterSec = 60; // Cap Retry-After at 60s, fail if longer +}; + +class HttpRetryHelper +{ +public: + using RequestFunc = std::function; + + /** + * Execute an HTTP request with retry logic. + * + * Calls makeRequest() to obtain a QNetworkReply*, waits for completion + * (with timeout), and retries on 429 or 5xx responses up to policy.maxRetries times. + * + * On 429 with Retry-After header: uses that delay (capped at maxRetryAfterSec). + * If Retry-After exceeds cap, fails immediately. + * + * Backoff: up to base * 2^attempt, multiplied by random jitter in [0.5, 1.0) + * (so with base=1000ms: 0.5-1s, 1-2s, 2-4s). Overridden by Retry-After when + * present and larger. + * + * @param makeRequest Callable that creates and sends a QNetworkReply* + * @param policy Retry policy configuration + * @param timeoutMs Per-request timeout in milliseconds + * @param abortFlag Optional atomic flag; if set to non-zero, aborts retries + * @return The final QNetworkReply* (caller must call deleteLater()) + */ + static QNetworkReply* + execute(const RequestFunc& makeRequest, const RetryPolicy& policy, int timeoutMs, QAtomicInt* abortFlag = nullptr); + + /** + * Check if an HTTP status code is retryable. + * Returns true for 429 (Too Many Requests) and 5xx (Server Error). + */ + static bool isRetryable(int httpStatus); + +private: + HttpRetryHelper() = default; +}; + +#endif // KEEPASSXC_HTTPRETRYHELPER_H diff --git a/src/remotesync/NextcloudLoginFlow.cpp b/src/remotesync/NextcloudLoginFlow.cpp new file mode 100644 index 0000000000..f32791c5a6 --- /dev/null +++ b/src/remotesync/NextcloudLoginFlow.cpp @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "NextcloudLoginFlow.h" + +#include "NextcloudSyncProvider.h" +#include "config-keepassx.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Locked banner strings. +// #1: 5-minute hard timeout fired. +// #2: phishing-mitigation -- loginUrl host/port did not match configured server. +// #3: initiate POST failure (network error / malformed JSON / missing +// fields) -- same locked text in all three sub-cases. +// #4: any polling-side hard failure (401/403/5xx/other-4xx, network error, +// 200+missing key, 200+empty value -- server defects surface through +// the network-error banner). +// --------------------------------------------------------------------------- +static const char* const BANNER_1_TIMEOUT = "Nextcloud authorization timed out. Click Authorize to try again."; +static const char* const BANNER_2_PHISHING = + "Nextcloud returned an unexpected authorization URL. Verify your server URL is correct."; +static const char* const BANNER_3_INITIATE_FAIL = + "Could not start Nextcloud authorization. Verify your server URL and try again."; +static const char* const BANNER_4_NETWORK_ERROR = + "Lost connection to Nextcloud during authorization. Click Authorize to try again."; + +NextcloudLoginFlow::NextcloudLoginFlow(QObject* parent) + : QObject(parent) +{ + setObjectName(QStringLiteral("NextcloudLoginFlow")); + + // Default browser opener: production calls QDesktopServices::openUrl. + // Tests inject a recorder via setBrowserOpener() before driving startLoginFlow. + m_browserOpener = [](const QUrl& loginUrl) { QDesktopServices::openUrl(loginUrl); }; + + // Timers exist from construction so the dtor cleans them up + // unconditionally. + m_pollTimer = new QTimer(this); + m_pollTimer->setSingleShot(false); + connect(m_pollTimer, &QTimer::timeout, this, &NextcloudLoginFlow::onPollTick); + + m_timeoutTimer = new QTimer(this); + m_timeoutTimer->setSingleShot(true); + connect(m_timeoutTimer, &QTimer::timeout, this, &NextcloudLoginFlow::onPollTimeoutFired); +} + +NextcloudLoginFlow::~NextcloudLoginFlow() +{ + // Cancel in-flight flow before teardown. + cancel(); +} + +void NextcloudLoginFlow::setNetworkAccessManager(QNetworkAccessManager* nam) +{ + // Caller-owned; see NextcloudLoginFlow.h. + m_nam = nam; +} + +void NextcloudLoginFlow::ensureNam() +{ + if (!m_nam) { + m_nam = new QNetworkAccessManager(this); + } +} + +void NextcloudLoginFlow::setBrowserOpener(std::function opener) +{ + if (opener) { + m_browserOpener = std::move(opener); + } +} + +void NextcloudLoginFlow::setPollIntervalMsForTest(int ms) +{ + m_pollIntervalMs = ms; +} + +void NextcloudLoginFlow::setTimeoutMsForTest(int ms) +{ + m_pollTimeoutMs = ms; +} + +void NextcloudLoginFlow::startLoginFlow(const QString& serverBaseUrl) +{ + // Cancel any in-flight previous flow first. cancel() is idempotent on + // Idle state (no-op) and tears down both timers + active reply on + // Polling/Initiating state, emitting loginCancelled exactly once. MUST + // be the first statement so a rapid double-Authorize never produces two + // concurrent poll loops. + cancel(); + + m_serverBaseUrl = NextcloudSyncProvider::canonicalizeServerBaseUrl(serverBaseUrl); + m_state = State::Initiating; + + ensureNam(); + + // Compose initiate URL: /index.php/login/v2 (preserves + // any configured subpath like "/nextcloud"). DecodedMode mirrors the + // sync-provider's canonical resource-URL composition. + QUrl initiateUrl(m_serverBaseUrl); + QString basePath = initiateUrl.path(); + initiateUrl.setPath(basePath + QStringLiteral("/index.php/login/v2"), QUrl::DecodedMode); + + QNetworkRequest req(initiateUrl); + // User-Agent identifies KeePassXC to the Nextcloud server. + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + // Refuse to follow redirects automatically; we surface redirect + // responses to the user as failures rather than silently chasing them + // (defense-in-depth against open-redirect-style auth detours). + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + + // Empty body is acceptable; Nextcloud's Login Flow v2 initiate spec + // sends no payload (Content-Length: 0). + QNetworkReply* reply = m_nam->post(req, QByteArray()); + + { + QMutexLocker lock(&m_replyMutex); + m_activeReply = reply; + } + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { onInitiateFinished(reply); }); +} + +void NextcloudLoginFlow::cancel() +{ + // Thread-safe cancel mirrors NextcloudSyncProvider::abort verbatim shape. + // Stop both timers first (idempotent), then marshal abort() to the + // reply's owning thread under the mutex. The reply's own finished() + // handler observes the cancel via either OperationCanceledError (which + // onPollFinished silently absorbs) or the QPointer becoming null + // (already-deleted reply). + m_pollTimer->stop(); + m_timeoutTimer->stop(); + + { + QMutexLocker locker(&m_replyMutex); + if (m_activeReply) { + QMetaObject::invokeMethod(m_activeReply, "abort", Qt::QueuedConnection); + } + } + + // Only transient states (Initiating / Polling) have an "active" flow to + // cancel. Completed and Failed are terminal -- they already emitted + // their respective signal, and re-emitting loginCancelled here would + // undo a valid authorized UI state on dtor or on the cancel-previous + // at the top of startLoginFlow. Idle has nothing to cancel. + if (m_state == State::Initiating || m_state == State::Polling) { + m_state = State::Idle; + emit loginCancelled(); + } +} + +void NextcloudLoginFlow::onInitiateFinished(QNetworkReply* reply) +{ + // Standard Qt idiom: defer reply destruction so any further signal + // emissions on the reply (e.g. from Mock) finish first. + reply->deleteLater(); + + { + QMutexLocker lock(&m_replyMutex); + if (m_activeReply == reply) { + m_activeReply.clear(); + } + } + + // OperationCanceledError comes from cancel()/abort() during initiate. + // cancel() already emitted loginCancelled and moved the state to Idle; + // emitting loginFailed here would be a second terminal signal for the + // same user action. Mirrors onPollFinished's absorber. + if (reply->error() == QNetworkReply::OperationCanceledError) { + return; + } + + // Network error -> banner #3 (initiate failure). Same banner text + // regardless of which sub-case fails (network, malformed JSON, missing + // fields). + if (reply->error() != QNetworkReply::NoError) { + emitFailureWithBanner(tr(BANNER_3_INITIATE_FAIL)); + return; + } + + const QByteArray body = reply->readAll(); + + QJsonParseError parseErr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isObject()) { + emitFailureWithBanner(tr(BANNER_3_INITIATE_FAIL)); + return; + } + + const QJsonObject root = doc.object(); + const QString loginUrlStr = root.value(QStringLiteral("login")).toString(); + const QJsonObject pollObj = root.value(QStringLiteral("poll")).toObject(); + const QString pollToken = pollObj.value(QStringLiteral("token")).toString(); + const QString pollEndpointStr = pollObj.value(QStringLiteral("endpoint")).toString(); + + if (loginUrlStr.isEmpty() || pollToken.isEmpty() || pollEndpointStr.isEmpty()) { + emitFailureWithBanner(tr(BANNER_3_INITIATE_FAIL)); + return; + } + + // Phishing mitigation: validate that the server-provided loginUrl AND + // pollEndpoint scheme + host + port match the configured server URL. Per + // Nextcloud server issue 21698 the server can return a loginUrl with an + // attacker-controlled origin; opening it in the user's browser would phish + // their credentials. The pollEndpoint flows from the same untrusted JSON + // and would otherwise let the server downgrade the polling channel to + // http:// (sending the app-password over cleartext) -- check it too. + const QUrl loginUrl(loginUrlStr); + const QUrl pollEndpoint(pollEndpointStr); + const QUrl configuredUrl(m_serverBaseUrl); + if (!hostsAndPortsMatch(loginUrl, configuredUrl) || !hostsAndPortsMatch(pollEndpoint, configuredUrl)) { + emitFailureWithBanner(tr(BANNER_2_PHISHING)); + return; + } + + // Stash for the polling state machine. + m_pollToken = pollToken; + m_pollEndpoint = pollEndpoint; + + // Pre-set Polling state so signal ordering reflects the post-initiate + // lifecycle. The actual timer start happens in startPolling() below. + m_state = State::Polling; + + emit loginInitiated(loginUrl); + m_browserOpener(loginUrl); + + // Kick the poll loop. startPolling fires onPollTick once immediately so + // the user does not wait one full poll interval before the first + // attempt; subsequent ticks come from m_pollTimer (repeating). + startPolling(); +} + +bool NextcloudLoginFlow::hostsAndPortsMatch(const QUrl& a, const QUrl& b) +{ + // Scheme equality is the first gate. Without it, a configured + // https://cloud.example.com would accept a server-returned + // http://cloud.example.com/... -- the host+port still match but the + // browser would open an unencrypted URL, letting a network attacker + // observe the polling token (loginUrl) or app-password (pollEndpoint). + if (a.scheme() != b.scheme()) { + return false; + } + if (a.host(QUrl::EncodeUnicode) != b.host(QUrl::EncodeUnicode)) { + return false; + } + const int defaultPort = (a.scheme() == QStringLiteral("https")) ? 443 : 80; + return a.port(defaultPort) == b.port(defaultPort); +} + +void NextcloudLoginFlow::emitFailureWithBanner(const QString& bannerText) +{ + m_state = State::Failed; + emit loginFailed(bannerText); +} + +// --------------------------------------------------------------------------- +// Polling state machine. +// --------------------------------------------------------------------------- + +void NextcloudLoginFlow::startPolling() +{ + m_state = State::Polling; + + // Reset interval here (in case setPollIntervalMsForTest was called between + // ctor and startLoginFlow, which is the common test pattern). + m_pollTimer->setInterval(m_pollIntervalMs); + m_pollTimer->start(); + m_timeoutTimer->start(m_pollTimeoutMs); + + // Fire the first poll immediately so the user doesn't wait one full + // interval (5 seconds in production) before any progress visible to the + // UI. + onPollTick(); +} + +void NextcloudLoginFlow::stopPollingTimers() +{ + m_pollTimer->stop(); + m_timeoutTimer->stop(); + QMutexLocker locker(&m_replyMutex); + m_activeReply.clear(); +} + +void NextcloudLoginFlow::onPollTick() +{ + // Two guards against overlapping requests: + // * Skip when the flow already reached a terminal state (Completed / + // Failed / Cancelled). The timer can fire one more tick after + // stopPollingTimers() if a tick was already queued. + // * Skip when the previous reply is still in flight. A slow server can + // hold a reply longer than m_pollIntervalMs; without this guard each + // tick would stack a new POST on top, producing concurrent replies + // and racing terminal-signal emissions in onPollFinished. + if (m_state != State::Polling) { + return; + } + { + QMutexLocker locker(&m_replyMutex); + if (m_activeReply) { + return; + } + } + QNetworkRequest req(m_pollEndpoint); + // User-Agent identifies KeePassXC; mirrors the initiate request. + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + req.setRawHeader("Content-Type", "application/x-www-form-urlencoded"); + // ManualRedirectPolicy MUST be set per-request (the attribute is not + // QNAM-wide; setting it on the QNAM has no effect on requests built + // from a fresh QNetworkRequest). 3xx responses on the poll path are + // state-machine-meaningful (e.g. 303 = 2FA per nextcloud/server#32689), + // so silently chasing redirects would break the polling contract. + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + + QByteArray body = QByteArray("token=") + QUrl::toPercentEncoding(m_pollToken); + QNetworkReply* reply = m_nam->post(req, body); + + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + } + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { onPollFinished(reply); }); +} + +void NextcloudLoginFlow::onPollFinished(QNetworkReply* reply) +{ + // Standard Qt idiom: defer reply destruction so any further signal + // emissions on the reply finish first. Mirrors onInitiateFinished. + reply->deleteLater(); + + { + QMutexLocker locker(&m_replyMutex); + if (m_activeReply == reply) { + m_activeReply.clear(); + } + } + + // OperationCanceledError comes from cancel()/abort() -- the cancel path has + // already torn down state and emitted loginCancelled. Silently absorb so we + // don't emit a second terminal signal. + if (reply->error() == QNetworkReply::OperationCanceledError) { + return; + } + + // State guard: if a concurrent reply (or the timeout timer) already drove + // the flow to a terminal state, swallow this late reply rather than + // emitting a second loginSucceeded / loginFailed. Combined with the + // overlap guard in onPollTick, this absorbs the rare race where two + // replies were already in flight before the guard landed. + if (m_state != State::Polling) { + return; + } + + // Network-level error (DNS resolution failure, connection refused) is a + // hard failure: stop polling and emit banner #4. NOT a 4xx/5xx HTTP status + // (those come back via QNetworkReply::NoError + the HttpStatusCodeAttribute). + if (reply->error() != QNetworkReply::NoError) { + const int httpStatusOnError = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatusOnError == 0) { + stopPollingTimers(); + m_state = State::Failed; + emit loginFailed(tr(BANNER_4_NETWORK_ERROR)); + return; + } + // else: HTTP status is set -- fall through to status-code dispatch. + } + + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Still-polling statuses: + // 3xx (any: 301/302/303/307/308) -- nextcloud/server#32689 (2FA returns 303) + // 404 -- nextcloud-desktop flow2auth.cpp silent-ignore alignment + // 410 -- per Nextcloud Login Flow v2 spec + if ((httpStatus >= 300 && httpStatus < 400) || httpStatus == 404 || httpStatus == 410) { + return; + } + + if (httpStatus == 200) { + // Parse JSON; require all 3 non-empty keys. Server defects that + // return 200 with missing-key OR empty-value are surfaced through + // the same banner #4 path as a network error -- the user sees + // "lost connection", which is the right user-facing characterization + // even though the underlying cause is a server-side malformed + // payload. + const QByteArray body = reply->readAll(); + QJsonParseError parseErr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isObject()) { + stopPollingTimers(); + m_state = State::Failed; + emit loginFailed(tr(BANNER_4_NETWORK_ERROR)); + return; + } + const QJsonObject root = doc.object(); + const QString server = root.value(QStringLiteral("server")).toString(); + const QString loginName = root.value(QStringLiteral("loginName")).toString(); + const QString appPassword = root.value(QStringLiteral("appPassword")).toString(); + if (server.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) { + stopPollingTimers(); + m_state = State::Failed; + emit loginFailed(tr(BANNER_4_NETWORK_ERROR)); + return; + } + stopPollingTimers(); + m_state = State::Completed; + emit loginCompleted(loginName, appPassword); + return; + } + + // Any other status (401, 403, 5xx, other 4xx like 400/418): hard failure. + stopPollingTimers(); + m_state = State::Failed; + emit loginFailed(tr(BANNER_4_NETWORK_ERROR)); +} + +void NextcloudLoginFlow::onPollTimeoutFired() +{ + // 5-minute hard cap reached. Stop everything and surface banner #1. + stopPollingTimers(); + m_state = State::Failed; + emit loginFailed(tr(BANNER_1_TIMEOUT)); +} diff --git a/src/remotesync/NextcloudLoginFlow.h b/src/remotesync/NextcloudLoginFlow.h new file mode 100644 index 0000000000..0b6de8dd1c --- /dev/null +++ b/src/remotesync/NextcloudLoginFlow.h @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_NEXTCLOUDLOGINFLOW_H +#define KEEPASSXC_NEXTCLOUDLOGINFLOW_H + +#include +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QJsonObject; + +/** + * Nextcloud Login Flow v2 driver: browser handshake + polling + cancel-previous + * semantics. + * + * Owns the initiate-POST -> browser handshake -> 5s polling -> token receipt + * state machine. Mirrors OAuthHttpServer's separation pattern: a self-contained + * auth class in src/remotesync/, owning its transport, exposing terminal + * signals. + */ +class NextcloudLoginFlow : public QObject +{ + Q_OBJECT + +public: + explicit NextcloudLoginFlow(QObject* parent = nullptr); + ~NextcloudLoginFlow() override; + + // Begin a Login Flow v2 handshake: POST initiate to + // /index.php/login/v2, validate the returned loginUrl host (phishing + // mitigation), open it in the user's browser, and start polling for + // credentials. Cancels any previously in-flight flow first. + // + // virtual so MockNextcloudLoginFlow can override and emit canned terminal + // signals synchronously, mirroring DropboxLoginFlow::startAuthorization + // (which is the virtual that MockDropboxLoginFlow overrides for the same + // reason). + virtual void startLoginFlow(const QString& serverBaseUrl); + + // Cancel any in-flight initiate or polling request and emit + // loginCancelled. Safe to call from any thread; abort is marshalled to + // the network thread via QueuedConnection. Idempotent on Idle state. + // + // virtual to symmetrically allow MockNextcloudLoginFlow to short-circuit + // the cancel teardown -- the mock has no timers / NAM / active reply to + // tear down, so a base-class cancel() would be a no-op anyway, but + // virtualizing keeps the override site contract identical to startLoginFlow. + virtual void cancel(); + + // Test seam -- production uses the lazily-constructed default. Caller + // retains ownership: the injected NAM must outlive this object, is never + // delete-d or reparented by the setter, and calling with nullptr does + // not free a previously-set NAM. + void setNetworkAccessManager(QNetworkAccessManager* nam); + + // Test seam -- production uses QDesktopServices::openUrl. Empty + // std::function is ignored. + void setBrowserOpener(std::function opener); + + // Test-only: override the 5-second polling interval / 5-minute polling + // timeout. Production uses the constants below. + void setPollIntervalMsForTest(int ms); + void setTimeoutMsForTest(int ms); + + // Production polling interval: 5 seconds (server-side recommended + // cadence). Production polling timeout: 5 minutes -- shorter than the + // server's 20-minute token lifetime. + static constexpr int PollIntervalMs = 5000; + static constexpr int PollTimeoutMs = 300000; + +signals: + // Emitted once the initiate POST succeeds and host validation passes; + // carries the loginUrl that was just opened in the user's browser. + void loginInitiated(QUrl loginUrl); + + // Emitted on successful Login Flow v2 completion; payload is the + // credentials the settings widget persists into the database's + // CustomData. + void loginCompleted(QString loginName, QString appPassword); + + // Emitted on hard failure (timeout, malformed body, host-validation + // rejection, network error) -- carries the user-facing banner verbatim. + void loginFailed(QString reason); + + // Emitted exactly once when cancel() succeeds in stopping an active flow. + void loginCancelled(); + +private slots: + // Handle the initiate POST response. Parses JSON, validates loginUrl + // host, emits loginInitiated and opens the browser on success, or emits + // loginFailed with the locked banner on failure. + void onInitiateFinished(QNetworkReply* reply); + + // Fire one poll request. Connected to m_pollTimer::timeout (repeating) + // and called once directly from startPolling so the user does not wait + // one full poll interval before the first attempt. + void onPollTick(); + + // Handle a poll response. Dispatches by HTTP status: 200+full keys -> + // loginCompleted, 200+missing/empty key -> failure banner, 3xx/404/410 + // -> continue polling, 401/403/5xx/other -> failure banner, network + // error -> failure banner. + void onPollFinished(QNetworkReply* reply); + + // m_timeoutTimer (single-shot 5-min) fired -- emit hard-timeout banner + // and tear down both timers / active reply. + void onPollTimeoutFired(); + +private: + // State machine for the login flow lifecycle. + enum class State + { + Idle, + Initiating, + Polling, + Completed, + Failed, + Cancelled + }; + + // Lazy-construct or return the injected QNetworkAccessManager. Mirrors + // NextcloudSyncProvider::ensureNam. + void ensureNam(); + + // Phishing mitigation: scheme equality, then case-sensitive host + // comparison on the EncodeUnicode form (handles IDN), then port comparison + // falling back to the scheme default (443/https, 80/http). Scheme must + // match because otherwise an https-configured server could return an + // http:/// login URL or poll endpoint and downgrade the + // channel. Returns true iff both origins match. + static bool hostsAndPortsMatch(const QUrl& a, const QUrl& b); + + // DRY emit helper: transition to Failed and emit + // loginFailed(bannerText). + void emitFailureWithBanner(const QString& bannerText); + + // Kick off the poll loop after a successful initiate. Sets m_state = + // Polling, starts m_pollTimer (repeating m_pollIntervalMs) and + // m_timeoutTimer (single-shot m_pollTimeoutMs), then fires onPollTick() + // directly so the first poll does not wait one full interval. + void startPolling(); + + // Stop both timers and clear m_activeReply under m_replyMutex. Called + // from terminal branches in onPollFinished, onPollTimeoutFired, and + // cancel(). Idempotent. + void stopPollingTimers(); + + QNetworkAccessManager* m_nam = nullptr; + + std::function m_browserOpener; + + int m_pollIntervalMs = PollIntervalMs; + int m_pollTimeoutMs = PollTimeoutMs; + + QString m_serverBaseUrl; + QString m_pollToken; + QUrl m_pollEndpoint; + + QPointer m_activeReply; + mutable QMutex m_replyMutex; + + QTimer* m_pollTimer = nullptr; + QTimer* m_timeoutTimer = nullptr; + + State m_state = State::Idle; + + Q_DISABLE_COPY(NextcloudLoginFlow) +}; + +#endif // KEEPASSXC_NEXTCLOUDLOGINFLOW_H diff --git a/src/remotesync/NextcloudSyncProvider.cpp b/src/remotesync/NextcloudSyncProvider.cpp new file mode 100644 index 0000000000..a8172f4eb1 --- /dev/null +++ b/src/remotesync/NextcloudSyncProvider.cpp @@ -0,0 +1,1084 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "NextcloudSyncProvider.h" + +#include "HttpRetryHelper.h" +#include "RemoteSyncParams.h" +#include "config-keepassx.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +NextcloudSyncProvider::NextcloudSyncProvider(QObject* parent) + : RemoteSyncProvider(parent) +{ + setObjectName(QStringLiteral("NextcloudSyncProvider")); + m_abortFlag.storeRelease(0); +} + +NextcloudSyncProvider::~NextcloudSyncProvider() = default; + +void NextcloudSyncProvider::setNetworkAccessManager(QNetworkAccessManager* nam) +{ + // Caller-owned; see NextcloudSyncProvider.h. + m_nam = nam; +} + +void NextcloudSyncProvider::ensureNam() +{ + if (!m_nam) { + m_nam = new QNetworkAccessManager(this); + } +} + +QString NextcloudSyncProvider::displayName() const +{ + return QStringLiteral("Nextcloud"); +} + +RemoteSyncParams* NextcloudSyncProvider::createParams() const +{ + auto* p = new NextcloudSyncParams; + p->type = QStringLiteral("nextcloud"); + return p; +} + +RemoteSyncParams* NextcloudSyncProvider::buildParamsFromConfig(const QJsonObject& config) const +{ + auto* base = createParams(); + auto* p = static_cast(base); + p->serverBaseUrl = config.value(QStringLiteral("serverBaseUrl")).toString(); + p->remotePath = config.value(QStringLiteral("remotePath")).toString(); + p->loginName = config.value(QStringLiteral("loginName")).toString(); + p->appPassword = config.value(QStringLiteral("appPassword")).toString(); + p->timeoutMsec = config.value(QStringLiteral("timeoutMsec")).toInt(30000); + return p; +} + +// --------------------------------------------------------------------------- +// download() WebDAV GET with Basic auth + User-Agent + ETag capture. +// No qDebug/qWarning prints of m_lastETag, appPassword, loginName, or the +// Authorization header value (no-secrets-in-logs). +// Do NOT call QNetworkReply::ignoreSslErrors here or anywhere in this +// provider. SSL handshake failures surface to the user as an actionable +// banner; the provider assumes a publicly-trusted CA. +// --------------------------------------------------------------------------- +RemoteHandler::RemoteResult NextcloudSyncProvider::download(const RemoteSyncParams* params) +{ + // Public-method body is a single-line wrapper around + // retryOnAuthOnce(downloadImpl). The abort-flag reset is hoisted here + // (BEFORE retryOnAuthOnce) so that an abort() called between the + // first-attempt's post-execute check and the helper's backoff-wake cannot + // be swallowed by a downloadImpl re-entry resetting the flag. + m_abortFlag.storeRelease(0); + return retryOnAuthOnce([this, params]() { return downloadImpl(params); }); +} + +RemoteHandler::RemoteResult NextcloudSyncProvider::downloadImpl(const RemoteSyncParams* params) +{ + // Safe: the factory and buildParamsFromConfig pair providers and params + // by type, so a NextcloudSyncProvider only ever receives + // NextcloudSyncParams. Same applies to uploadImpl below. + auto* ncParams = static_cast(params); + + // Validate required fields. remotePath must start with '/' (NFC-normalized at + // save time; we do not re-normalize here). + if (ncParams->serverBaseUrl.isEmpty()) { + return {false, tr("Nextcloud server URL is required"), {}, {}, {}}; + } + if (ncParams->loginName.isEmpty()) { + return {false, tr("Nextcloud login name is required"), {}, {}, {}}; + } + if (ncParams->remotePath.isEmpty() || !ncParams->remotePath.startsWith(QLatin1Char('/'))) { + return {false, tr("Remote path must start with '/'"), {}, {}, {}}; + } + + ensureNam(); + + // Clear stale ETag so a failed download doesn't leak into the next upload. + // m_abortFlag is reset in download()'s public wrapper, not here. + m_lastETag.clear(); + + const QString canonicalBase = canonicalizeServerBaseUrl(ncParams->serverBaseUrl); + const QUrl resourceUrl = buildResourceUrl(canonicalBase, ncParams->loginName, ncParams->remotePath); + + // Basic-auth: base64(loginName ":" appPassword). Both buffers zeroed before + // return for stack hygiene (QByteArray fill('\0')). + QByteArray basicCreds = (ncParams->loginName + QLatin1Char(':') + ncParams->appPassword).toUtf8(); + QByteArray authHeader = QByteArray("Basic ") + basicCreds.toBase64(); + + auto makeRequest = [this, &resourceUrl, &authHeader]() -> QNetworkReply* { + QNetworkRequest req(resourceUrl); + req.setRawHeader("Authorization", authHeader); + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + + QNetworkReply* reply = m_nam->get(req); + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, ncParams->timeoutMsec, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + authHeader.fill('\0'); + basicCreds.fill('\0'); + + if (!reply) { + return {false, tr("Network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Network-level errors (no HTTP status received). SSL handshake gets the + // locked banner; everything else falls through to a generic + // "Network error: " string. + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::SslHandshakeFailedError) { + reply->deleteLater(); + return {false, + tr("Nextcloud server's SSL certificate could not be verified. " + "Check that your server's certificate is valid and the chain " + "is correctly configured."), + {}, + {}, + {}}; + } + QString errorMsg = tr("Network error: %1").arg(reply->errorString()); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}}; + } + + // 404 distinction: look up the file in trashbin to differentiate a + // first-sync (silent success) from an in-trash situation (actionable + // banner). The PROPFIND helper returns false on any failure, which collapses + // both "no trashbin" and "not in trash" to first-sync semantics. + if (httpStatus == HttpNotFound) { + const QString filename = QFileInfo(ncParams->remotePath).fileName(); + reply->deleteLater(); + const bool inTrash = checkIfInTrash(ncParams, filename); + // Abort during the trashbin PROPFIND must surface as cancelled, not + // silent first-sync success. checkIfInTrash returns false on any + // failure (including abort) so without this check an aborted trash + // lookup would route through the first-sync branch. + if (m_abortFlag.loadAcquire() != 0) { + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + if (inTrash) { + return {false, + tr("Database is in your Nextcloud trash. Restore it from " + "Nextcloud Files, then try syncing again."), + {}, + {}, + {}}; + } + // First-sync silent success -- same shape as Dropbox's + // path/not_found 409 handling. + return {true, {}, {}, {}, {}}; + } + + if (httpStatus != HttpOk) { + // Drain body for hygiene. Per-status locked banners go through the + // centralized mapWebdavStatusToMessage helper. 401/403/423/507 get + // their locked banners; 5xx gets a generic server-error banner; + // everything else gets a fallback "Nextcloud returned HTTP %1." banner. + reply->readAll(); + QString errorMsg = mapWebdavStatusToMessage(httpStatus); + ErrorKind kind = mapWebdavStatusToKind(httpStatus); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}, kind}; + } + + // 200 success -- ETag Lifecycle State Machine. + // Preserve opaque-tag quotes byte-for-byte. + // OC-Etag fallback for older Nextcloud / external-storage backends + // (nextcloud/server#14103). + // Weak detection sets sticky m_serverEmitsWeakETags AND emits + // weakEtagDetected() exactly once per provider lifetime. The flag is + // sticky -- once set, no subsequent strong ETag clears it (protects + // against mid-session server "fixes" that can't be trusted to remain + // stable). + QByteArray etagRaw = reply->rawHeader("ETag"); + if (etagRaw.isEmpty()) { + etagRaw = reply->rawHeader("OC-Etag"); + } + QString etag = QString::fromLatin1(etagRaw); + + if (etag.startsWith(QLatin1String("W/"))) { + // Weak ETag -- skip storage; mark session; emit warning ONCE. + // m_lastETag was reset at function entry; leave it cleared so the + // next upload uses silent-overwrite semantics (NEITHER conditional + // header) per RFC 7232 §2.3. + m_lastETag.clear(); + if (!m_serverEmitsWeakETags) { + m_serverEmitsWeakETags = true; + emit weakEtagDetected(); + } + } else if (!etag.isEmpty() && etag != QLatin1String("\"\"")) { + // Strong ETag -- store verbatim (quotes preserved byte-for-byte). + // Empty-quoted ETag ("") collapses to "no useful ETag" so we leave + // m_lastETag empty (-> first-upload semantics). + m_lastETag = etag; + } + // else: m_lastETag stays empty (server emitted neither header, or only an + // empty-quoted one) -- forms first-upload semantics for the next upload. + + // Body length sanity check before reading (avoids OOM on malicious responses). + qint64 contentLength = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + if (contentLength > MaxDatabaseSize) { + reply->deleteLater(); + return {false, tr("Downloaded file exceeds size limit (%1 bytes)").arg(contentLength), {}, {}, {}}; + } + + QByteArray fileData = reply->readAll(); + reply->deleteLater(); + + // Trust boundary: actual size check (Content-Length may be absent). + if (fileData.size() > MaxDatabaseSize) { + return {false, tr("Downloaded file exceeds size limit (%1 bytes)").arg(fileData.size()), {}, {}, {}}; + } + + // Write to a QTemporaryFile (caller owns the path; setAutoRemove(false) so + // SyncEngine can read it after this function returns). Mirrors Dropbox. + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(false); + if (!tmpFile.open()) { + return {false, tr("Failed to create temporary file"), {}, {}, {}}; + } + if (tmpFile.write(fileData) != fileData.size()) { + tmpFile.remove(); + return {false, tr("Failed to write temporary file"), {}, {}, {}}; + } + tmpFile.close(); + + return {true, {}, tmpFile.fileName(), {}, {}}; +} + +// --------------------------------------------------------------------------- +// Minimal PROPFIND request body shared by testConnection() and checkIfInTrash(). +// Some reverse-proxy configurations reject PROPFIND with an empty body, so we +// always send this canonical XML body. The body requests only +// (the cheapest property to compute on the server). +// --------------------------------------------------------------------------- +static const QByteArray propfindBody = "\n" + "\n" + " \n" + "\n"; + +// --------------------------------------------------------------------------- +// testConnection() -- PROPFIND Depth: 0 against the configured remote path. +// Treats 200 OR 207 (Multi-Status) as success; non-success routes through +// mapWebdavStatusToMessage for per-status locked banners. +// --------------------------------------------------------------------------- +RemoteHandler::RemoteResult NextcloudSyncProvider::testConnection(const NextcloudSyncParams* params) +{ + // Public-method body is a single-line wrapper around + // retryOnAuthOnce(testConnectionImpl). Abort flag reset is performed here + // (see download() above). The credential-rejection banner is emitted from + // testConnectionImpl, not from this wrapper. + m_abortFlag.storeRelease(0); + return retryOnAuthOnce([this, params]() { return testConnectionImpl(params); }); +} + +RemoteHandler::RemoteResult NextcloudSyncProvider::testConnectionImpl(const NextcloudSyncParams* params) +{ + // Callers always pass a constructed NextcloudSyncParams; a null here + // is a wiring bug rather than a user-facing failure. + Q_ASSERT(params); + + if (params->serverBaseUrl.isEmpty()) { + return {false, tr("Nextcloud server URL is required"), {}, {}, {}}; + } + if (params->loginName.isEmpty()) { + return {false, tr("Nextcloud login name is required"), {}, {}, {}}; + } + if (params->remotePath.isEmpty() || !params->remotePath.startsWith(QLatin1Char('/'))) { + return {false, tr("Remote path must start with '/'"), {}, {}, {}}; + } + + ensureNam(); + + const QString canonicalBase = canonicalizeServerBaseUrl(params->serverBaseUrl); + const QUrl resourceUrl = buildResourceUrl(canonicalBase, params->loginName, params->remotePath); + + QByteArray basicCreds = (params->loginName + QLatin1Char(':') + params->appPassword).toUtf8(); + QByteArray authHeader = QByteArray("Basic ") + basicCreds.toBase64(); + + auto makeRequest = [this, &resourceUrl, &authHeader]() -> QNetworkReply* { + QNetworkRequest req(resourceUrl); + req.setRawHeader("Authorization", authHeader); + req.setRawHeader("Depth", "0"); + req.setRawHeader("Content-Type", "application/xml"); + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + + QNetworkReply* reply = m_nam->sendCustomRequest(req, "PROPFIND", propfindBody); + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, params->timeoutMsec, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + authHeader.fill('\0'); + basicCreds.fill('\0'); + + if (!reply) { + return {false, tr("Network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::SslHandshakeFailedError) { + reply->deleteLater(); + return {false, + tr("Nextcloud server's SSL certificate could not be verified. " + "Check that your server's certificate is valid and the chain " + "is correctly configured."), + {}, + {}, + {}}; + } + QString errorMsg = tr("Network error: %1").arg(reply->errorString()); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}}; + } + + reply->readAll(); // drain body for hygiene -- we only care about the status + reply->deleteLater(); + + if (httpStatus == HttpMultiStatus || httpStatus == HttpOk) { + // 207/200 means the configured remote path exists on the server. Populate + // filePath with the remote path so the page-side caller can dispatch to + // "Nextcloud connection successful." (file exists) vs "Connected. File + // not found -- it will be created on first sync." (404 branch below, + // empty filePath). Mirrors Dropbox's download-as-test-connection contract + // where filePath emptiness signals first-sync state. + return {true, {}, params->remotePath, {}, {}}; + } + + // testConnection probes credentials the user just typed into the + // settings widget, so the actionable 401 message is "double-check what + // you pasted" -- not the "Re-authorize in Settings" wording that + // download() and upload() emit for an in-session 401. Same HTTP status, + // different user-facing string per call-site. classifyError dispatches + // both banners to ErrorKind::AuthExpired so retryOnAuthOnce treats them + // uniformly. + if (httpStatus == HttpUnauthorized) { + // reply was already drained + deleteLater()-ed above. + return {false, + tr("Nextcloud rejected those credentials. Verify the username and app password."), + {}, + {}, + {}, + ErrorKind::AuthExpired}; + } + + // 404 from a PROPFIND on the configured remote path means "auth accepted + + // server reachable + endpoint correct, but the file doesn't exist yet" -- + // i.e. first-sync semantics. Mirror DropboxSyncProvider::download's 404 + // handling: return success=true with empty filePath so the page-side caller + // and SyncEngine first-sync branch can both treat this as "auth OK, will be + // created on first sync." + if (httpStatus == HttpNotFound) { + return {true, {}, {}, {}, {}}; + } + + // Per-status locked banners via mapWebdavStatusToMessage for any other + // non-success status (403/412/423/507/5xx/etc). + return {false, mapWebdavStatusToMessage(httpStatus), {}, {}, {}, mapWebdavStatusToKind(httpStatus)}; +} + +// --------------------------------------------------------------------------- +// checkIfInTrash() -- PROPFIND Depth: 1 against the trashbin. +// Permissive contains() match on body bytes -- handles deletion-suffix format +// variance. Any failure (no trashbin, network error, non-multistatus +// response) collapses to false ("not in trash") -- which the download() +// caller interprets as a first-sync silent success. +// --------------------------------------------------------------------------- +bool NextcloudSyncProvider::checkIfInTrash(const NextcloudSyncParams* params, const QString& filename) +{ + if (filename.isEmpty()) { + return false; + } + + // Build trashbin URL from the canonical base, preserving any subpath + // (e.g. https://cloud.example.com/nextcloud -> .../nextcloud/remote.php/dav/...) + const QString canonicalBase = canonicalizeServerBaseUrl(params->serverBaseUrl); + QUrl trashUrl(canonicalBase); + const QString encodedLogin = QString::fromUtf8(QUrl::toPercentEncoding(params->loginName)); + QString trashPath = + trashUrl.path() + QStringLiteral("/remote.php/dav/trashbin/") + encodedLogin + QStringLiteral("/trash"); + trashUrl.setPath(trashPath, QUrl::DecodedMode); + + QByteArray basicCreds = (params->loginName + QLatin1Char(':') + params->appPassword).toUtf8(); + QByteArray authHeader = QByteArray("Basic ") + basicCreds.toBase64(); + + auto makeRequest = [this, &trashUrl, &authHeader]() -> QNetworkReply* { + QNetworkRequest req(trashUrl); + req.setRawHeader("Authorization", authHeader); + req.setRawHeader("Depth", "1"); + req.setRawHeader("Content-Type", "application/xml"); + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + + QNetworkReply* reply = m_nam->sendCustomRequest(req, "PROPFIND", propfindBody); + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, params->timeoutMsec, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + authHeader.fill('\0'); + basicCreds.fill('\0'); + + if (!reply) { + return false; + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus != HttpMultiStatus && httpStatus != HttpOk) { + // 404 (no trashbin endpoint), 401 (auth issue), 5xx (server error) all + // collapse to false -- "not in trash" / first-sync semantics. + reply->readAll(); + reply->deleteLater(); + return false; + } + + QByteArray body = reply->readAll(); + reply->deleteLater(); + + // Permissive parse: look for the filename anywhere in the response body. + // Nextcloud appends a deletion-timestamp suffix (e.g. ".d1234567890") to + // the filename in ; the contains() match works across suffix + // variants. The filename itself is always present byte-for-byte before + // the suffix. + return body.contains(filename.toUtf8()); +} + +// --------------------------------------------------------------------------- +// upload() WebDAV PUT with three-way conditional header selection + ETag +// round-trip + 412 conflict surface. +// No qDebug/qWarning prints of m_lastETag, appPassword, loginName, fileData, +// or the Authorization header value (no-secrets-in-logs). +// Do NOT call QNetworkReply::ignoreSslErrors here or anywhere in this +// provider. SSL handshake failures surface to the user as an actionable +// banner; the provider assumes a publicly-trusted CA. +// +// Three-way conditional-header tree (RFC 7232 §2.3): +// m_serverEmitsWeakETags=true -> NEITHER (silent overwrite) +// m_lastETag empty -> If-None-Match: * (first upload) +// m_lastETag non-empty -> If-Match: (update) +// --------------------------------------------------------------------------- +RemoteHandler::RemoteResult NextcloudSyncProvider::upload(const QString& filePath, const RemoteSyncParams* params) +{ + // Public-method body is a single-line wrapper around + // retryOnAuthOnce(uploadImpl). Abort flag reset hoisted here (see + // download() comment). + m_abortFlag.storeRelease(0); + return retryOnAuthOnce([this, &filePath, params]() { return uploadImpl(filePath, params); }); +} + +RemoteHandler::RemoteResult NextcloudSyncProvider::uploadImpl(const QString& filePath, const RemoteSyncParams* params) +{ + auto* ncParams = static_cast(params); + + if (ncParams->serverBaseUrl.isEmpty()) { + return {false, tr("Nextcloud server URL is required"), {}, {}, {}}; + } + if (ncParams->loginName.isEmpty()) { + return {false, tr("Nextcloud login name is required"), {}, {}, {}}; + } + if (ncParams->remotePath.isEmpty() || !ncParams->remotePath.startsWith(QLatin1Char('/'))) { + return {false, tr("Remote path must start with '/'"), {}, {}, {}}; + } + if (filePath.isEmpty()) { + return {false, tr("Local file path is required"), {}, {}, {}}; + } + + // Read file contents into memory. Body is sent in a single PUT (no + // chunked-upload split -- Nextcloud 16+ accepts up to 512 MiB by + // default; we cap at MaxDatabaseSize=256 MiB which is well under). + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + return {false, tr("Failed to open file for upload: %1").arg(filePath), {}, {}, {}}; + } + if (file.size() > MaxDatabaseSize) { + return {false, tr("File exceeds size limit (%1 bytes)").arg(file.size()), {}, {}, {}}; + } + QByteArray fileData = file.readAll(); + file.close(); + + ensureNam(); + + const QString canonicalBase = canonicalizeServerBaseUrl(ncParams->serverBaseUrl); + const QUrl resourceUrl = buildResourceUrl(canonicalBase, ncParams->loginName, ncParams->remotePath); + + QByteArray basicCreds = (ncParams->loginName + QLatin1Char(':') + ncParams->appPassword).toUtf8(); + QByteArray authHeader = QByteArray("Basic ") + basicCreds.toBase64(); + + // Snapshot the conditional-header decision BEFORE the network call. The + // m_lastETag and m_serverEmitsWeakETags fields are stable across the + // single PUT (we don't run any parallel downloads), but capturing the + // values into locals makes the lambda's intent explicit. + const bool weakSession = m_serverEmitsWeakETags; + const QByteArray lastEtagBytes = m_lastETag.toLatin1(); + const bool firstUpload = lastEtagBytes.isEmpty(); + + auto makeRequest = + [this, &resourceUrl, &authHeader, &fileData, weakSession, firstUpload, &lastEtagBytes]() -> QNetworkReply* { + QNetworkRequest req(resourceUrl); + req.setRawHeader("Authorization", authHeader); + req.setRawHeader("User-Agent", QByteArray("KeePassXC/") + KEEPASSXC_VERSION); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/octet-stream")); + + // Three-way conditional-header tree -- mutually exclusive per RFC 7232. + if (weakSession) { + // Silent overwrite: NEITHER conditional header. Sending + // If-Match: W/"..." would be an RFC 7232 §2.3 violation; sending + // If-None-Match: * after we already know the file exists would + // make every upload fail. The session flag is the only sound + // option once the server is known to emit weak ETags. + } else if (firstUpload) { + // First-upload: file MUST NOT exist on remote. + req.setRawHeader("If-None-Match", "*"); + } else { + // Update: ETag MUST match prior download verbatim (quotes + // preserved byte-for-byte). + req.setRawHeader("If-Match", lastEtagBytes); + } + + QNetworkReply* reply = m_nam->put(req, fileData); + QMutexLocker locker(&m_replyMutex); + m_activeReply = reply; + return reply; + }; + + RetryPolicy policy; + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, ncParams->timeoutMsec, &m_abortFlag); + { + QMutexLocker locker(&m_replyMutex); + m_activeReply = nullptr; + } + authHeader.fill('\0'); + basicCreds.fill('\0'); + + if (!reply) { + return {false, tr("Network request failed"), {}, {}, {}, ErrorKind::Network}; + } + + if (m_abortFlag.loadAcquire() != 0) { + reply->deleteLater(); + return {false, tr("Operation cancelled"), {}, {}, {}, ErrorKind::Aborted}; + } + + int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Network-level errors (no HTTP status received). SSL handshake gets the + // locked banner; everything else falls through to a generic + // "Network error: " string. + if (httpStatus == 0 && reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::SslHandshakeFailedError) { + reply->deleteLater(); + return {false, + tr("Nextcloud server's SSL certificate could not be verified. " + "Check that your server's certificate is valid and the chain " + "is correctly configured."), + {}, + {}, + {}}; + } + QString errorMsg = tr("Network error: %1").arg(reply->errorString()); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}}; + } + + // 412 Precondition Failed: the conditional header didn't match. This is + // the conflict surface. Banner string is verbatim from Dropbox's + // path/conflict branch -- cross-provider consistency means users learn + // the conflict semantic once. + // + // m_lastETag is NOT cleared here: the captured ETag was still VALID at + // the time of capture; the conflict is about a server-side concurrent + // change, not our ETag becoming stale. The next download will refresh + // m_lastETag naturally; clearing it now would force an unnecessary + // first-upload retry that 409s against the now-existing file. + if (httpStatus == HttpPreconditionFailed) { + reply->readAll(); // drain body for hygiene + reply->deleteLater(); + return {false, + tr("Remote file changed since last download. Re-sync to merge changes."), + {}, + {}, + {}, + ErrorKind::Conflict}; + } + + // Non-success non-412 -- per-status locked banners via the centralized + // mapWebdavStatusToMessage helper. 401/403/423/507 get their locked + // banners; 5xx gets a generic server-error banner. + if (httpStatus != HttpOk && httpStatus != HttpCreated && httpStatus != HttpNoContent) { + reply->readAll(); // drain + QString errorMsg = mapWebdavStatusToMessage(httpStatus); + ErrorKind kind = mapWebdavStatusToKind(httpStatus); + reply->deleteLater(); + return {false, errorMsg, {}, {}, {}, kind}; + } + + // Success (200/201/204) -- refresh m_lastETag from response headers so + // the next upload in the same session uses the new value as If-Match + // (mirrors Dropbox m_lastRev refresh). OC-Etag fallback. Weak ETags on + // PUT response do NOT update m_lastETag and do NOT flip the session flag + // here -- the session flag is download-driven (set during the prior + // download's 200 branch); a weak ETag on the PUT response just means + // "skip the refresh" since we can't safely use it as If-Match next time. + QByteArray newEtagRaw = reply->rawHeader("ETag"); + if (newEtagRaw.isEmpty()) { + newEtagRaw = reply->rawHeader("OC-Etag"); + } + QString newEtag = QString::fromLatin1(newEtagRaw); + if (!newEtag.isEmpty() && !newEtag.startsWith(QLatin1String("W/")) && newEtag != QLatin1String("\"\"")) { + m_lastETag = newEtag; + } + + reply->deleteLater(); + return {true, {}, {}, {}, {}}; +} + +RemoteHandler::RemoteResult NextcloudSyncProvider::refreshAuth(const RemoteSyncParams* params) +{ + // App-password authentication has no token refresh: the Basic-auth credential + // is long-lived until the user revokes it via Nextcloud settings. + Q_UNUSED(params) + return {true, {}, {}, {}, {}}; +} + +// --------------------------------------------------------------------------- +// abort() implementation. Mirrors DropboxSyncProvider::abort byte-for-byte +// (storeRelease(1) + locked-mutex reply->abort()). The flag is checked by +// HttpRetryHelper::execute between retries (and during the retry-delay loop, +// polled every 100ms) so abort is responsive even when the provider is +// sleeping between retries. The post-execute abort check inside +// download/upload/testConnection surfaces the cancelled banner ("Operation +// cancelled") byte-for-byte. +// +// Mutex protects m_activeReply against the race where abort() and the +// transport function (download/upload/etc.) are on different threads. +// QMetaObject::invokeMethod with QueuedConnection marshals the reply +// abort to the reply's owning thread for thread safety. +// --------------------------------------------------------------------------- +void NextcloudSyncProvider::abort() +{ + m_abortFlag.storeRelease(1); + + QMutexLocker locker(&m_replyMutex); + if (m_activeReply) { + // Marshal abort() to the reply's owning thread for thread safety. + QMetaObject::invokeMethod(m_activeReply, "abort", Qt::QueuedConnection); + } +} + +// --------------------------------------------------------------------------- +// retryOnAuthOnce -- single-401-retry-after-backoff helper. Wraps download() +// / upload() / testConnection() with a "try once, if AuthExpired retry once +// after a backoff" policy. +// +// Contract: +// - First 401 is a TRANSIENT-ERROR opportunity: maybe the user's session +// was rotated mid-call by a Nextcloud admin password reset, maybe the +// server hiccupped, maybe a brief proxy auth issue. Retry once after a +// 2-second pause (production); if the second attempt also returns 401, +// surface the existing AuthExpired banner and let the user re-authorize. +// - First 401 NEVER auto-wipes stored credentials in CustomData. The +// retry uses the SAME credentials the first attempt used; we do not +// mutate params or provider state. +// - The internal AuthRevoked semantic (two consecutive 401s) maps to the +// SAME user-facing banner as AuthExpired (single 401 followed by retry +// fail) because the user action is identical -- "Re-authorize in +// Database > Settings > Cloud Sync." or "Verify the username and app +// password" depending on call site. +// +// HttpRetryHelper is deliberately NOT extended to host this behavior: the +// retry-once-on-401 policy is Nextcloud-specific (Dropbox uses OAuth2 +// refresh-then-retry; the contract is fundamentally different). The helper +// stays inside NextcloudSyncProvider. +// +// Abort polling during backoff: m_abortFlag is checked every 100ms during +// the backoff window so that abort() called between the first attempt and +// the second attempt returns the first-attempt result early WITHOUT making +// the second attempt. The 100ms slice is the same cadence HttpRetryHelper +// uses for its retry-delay loop -- the user-perceived abort latency is +// bounded at 100ms regardless of backoff length. +// --------------------------------------------------------------------------- +RemoteHandler::RemoteResult NextcloudSyncProvider::retryOnAuthOnce(std::function op) +{ + RemoteHandler::RemoteResult first = op(); + // Branch on the provider-set kind, not on the localized errorMessage. + // Substring-matching tr()'d strings silently breaks retry behavior on + // every non-English build. + if (first.success || first.kind != ErrorKind::AuthExpired) { + return first; + } + + // Single backoff before retry. Stored credentials in CustomData are + // NEVER touched here -- caller's params still hold the same appPassword. + // + // Poll m_abortFlag every 100ms during the backoff so abort() during + // retry returns early without making the second attempt. + constexpr int sliceMs = 100; + int waited = 0; + while (waited < m_retryBackoffMs) { + if (m_abortFlag.loadAcquire() != 0) { + return first; // Abort wins -- return the first-attempt result. + } + const int thisSlice = qMin(sliceMs, m_retryBackoffMs - waited); + QThread::msleep(static_cast(thisSlice)); + waited += thisSlice; + } + // Final abort check after the loop, in case abort() arrived in the + // last slice's window. Without this an abort firing in the final + // sleep slice would not be observed before the second attempt. + if (m_abortFlag.loadAcquire() != 0) { + return first; + } + + return op(); +} + +// --------------------------------------------------------------------------- +// Centralized HTTP-status -> locked banner-string mapper. +// Pure function of httpStatus (no instance state). Called from download(), +// upload(), and testConnection() non-success branches. Every banner here +// matches byte-for-byte the user-facing strings the classifyError dispatch +// pattern-matches against -- changing one without changing the other breaks +// UI dispatch. +// +// 500-series wording is not locked (only 401/403/404/412/423/507 are). The +// "Nextcloud server error (HTTP %1). Try again later." choice maps to +// ErrorKind::ServerError via the "server error" fragment in classifyError. +// --------------------------------------------------------------------------- +QString NextcloudSyncProvider::mapWebdavStatusToMessage(int httpStatus) +{ + switch (httpStatus) { + case HttpUnauthorized: // 401 + return tr("Nextcloud authorization expired. Re-authorize in Database > Settings > Cloud Sync."); + case HttpForbidden: // 403 + return tr("Nextcloud denied access to this path. Verify the file path and your account permissions."); + case HttpNotFound: // 404 (testConnection-side; download's 404-trash distinction routes through checkIfInTrash) + return tr("Nextcloud could not find the configured remote path. Verify your settings."); + case HttpPreconditionFailed: // 412 + return tr("Remote file changed since last download. Re-sync to merge changes."); + case HttpLocked: // 423 + return tr("Nextcloud file is locked. Try again in a moment."); + case HttpInsufficientStorage: // 507 + return tr("Nextcloud server is out of storage. Free space and try again."); + default: + if (httpStatus >= 500 && httpStatus < 600) { + return tr("Nextcloud server error (HTTP %1). Try again later.").arg(httpStatus); + } + return tr("Nextcloud returned HTTP %1.").arg(httpStatus); + } +} + +RemoteHandler::ErrorKind NextcloudSyncProvider::mapWebdavStatusToKind(int httpStatus) +{ + switch (httpStatus) { + case HttpUnauthorized: // 401 + return ErrorKind::AuthExpired; + case HttpForbidden: // 403 + return ErrorKind::Permission; + case HttpNotFound: // 404 + return ErrorKind::NotFound; + case HttpPreconditionFailed: // 412 + return ErrorKind::Conflict; + case HttpLocked: // 423 + return ErrorKind::RateLimit; + case HttpInsufficientStorage: // 507 + return ErrorKind::Quota; + default: + if (httpStatus >= 500 && httpStatus < 600) { + return ErrorKind::ServerError; + } + return ErrorKind::Other; + } +} + +// --------------------------------------------------------------------------- +// classifyError -- banner-text -> ErrorKind dispatch for the MessageWidget +// banner path. Mirrors the DropboxSyncProvider::classifyError keyword-pattern; +// case-insensitive substring matching against fragment keywords from the +// locked banner table. Each fragment is unique to its ErrorKind so accidental +// cross-matches are impossible. +// +// Conflict-surface invariant: the 412 banner string ("Remote file changed +// since last download...") dispatches to ErrorKind::Conflict here AND +// surfaces verbatim from upload(). The "restart" semantic is the next user +// sync, not an automatic in-engine loop. +// +// classifyError returns ErrorKind::Network for the SSL banner string. The +// provider NEVER calls QNetworkReply::ignoreSslErrors; SSL handshake failures +// surface to the user as an actionable banner. The provider assumes a +// publicly-trusted CA. +// --------------------------------------------------------------------------- +RemoteSyncProvider::ErrorKind NextcloudSyncProvider::classifyError(const QString& errorMessage) const +{ + // Order matters where fragments could in principle overlap; chosen here so + // the most specific match wins. Conflict's "Remote file changed" fragment + // is unique; trash's "Nextcloud trash" fragment is unique; etc. + if (errorMessage.contains(QStringLiteral("authorization expired"), Qt::CaseInsensitive)) { + return ErrorKind::AuthExpired; + } + // The credential-rejection banner from testConnection's 401 branch + // dispatches to AuthExpired (same as the standard 401 banner above) so + // retryOnAuthOnce treats both call-site banners uniformly. The fragment + // below cannot collide with any other Nextcloud banner string. + if (errorMessage.contains(QStringLiteral("rejected those credentials"), Qt::CaseInsensitive)) { + return ErrorKind::AuthExpired; + } + if (errorMessage.contains(QStringLiteral("denied access"), Qt::CaseInsensitive)) { + return ErrorKind::Permission; + } + if (errorMessage.contains(QStringLiteral("Nextcloud trash"), Qt::CaseInsensitive) + || errorMessage.contains(QStringLiteral("could not find the configured remote path"), Qt::CaseInsensitive)) { + return ErrorKind::NotFound; + } + if (errorMessage.contains(QStringLiteral("Remote file changed"), Qt::CaseInsensitive)) { + return ErrorKind::Conflict; + } + if (errorMessage.contains(QStringLiteral("file is locked"), Qt::CaseInsensitive)) { + return ErrorKind::RateLimit; + } + if (errorMessage.contains(QStringLiteral("out of storage"), Qt::CaseInsensitive)) { + return ErrorKind::Quota; + } + if (errorMessage.contains(QStringLiteral("SSL certificate"), Qt::CaseInsensitive)) { + // SSL handshake is a network-level failure for the abstraction; the + // ErrorKind enum has no dedicated SslHandshake value. + return ErrorKind::Network; + } + if (errorMessage.contains(QStringLiteral("server error"), Qt::CaseInsensitive)) { + return ErrorKind::ServerError; + } + return ErrorKind::Other; +} + +bool NextcloudSyncProvider::isAuthorized(const QJsonObject& config) const +{ + // Operational contract: a config is "authorized" only if it has every + // field required for a successful sync round-trip. + // - loginName + appPassword: Basic-auth credential pair + // - serverBaseUrl: WebDAV endpoint base; sync has no usable default + // - remotePath: target path under the user's files namespace + return !config.value(QStringLiteral("loginName")).toString().isEmpty() + && !config.value(QStringLiteral("appPassword")).toString().isEmpty() + && !config.value(QStringLiteral("serverBaseUrl")).toString().isEmpty() + && !config.value(QStringLiteral("remotePath")).toString().isEmpty(); +} + +// --------------------------------------------------------------------------- +// URL canonicalization helper. +// Idempotent -- applying twice is the same as applying once. +// --------------------------------------------------------------------------- +QString NextcloudSyncProvider::canonicalizeServerBaseUrl(QString input) +{ + input = input.trimmed(); + if (input.isEmpty()) { + return {}; + } + + // 1. Default scheme to https:// when absent + QUrl url(input); + if (url.scheme().isEmpty()) { + url = QUrl(QStringLiteral("https://") + input); + } + + // 2. Transport-security gate. We reject cleartext http:// for non-loopback + // hosts: the app-password is sent via the Authorization: Basic header on + // every download/upload/testConnection, so allowing http://example.com + // would leak the credential to any on-path observer. Loopback http is + // permitted as a dev / self-hosted local escape hatch. Any other scheme + // (ftp/file/etc.) is also rejected -- WebDAV-over-Basic only meaningfully + // makes sense over https or local http. + const QString scheme = url.scheme(); + if (scheme == QStringLiteral("http")) { + if (!isLoopbackHost(url)) { + return {}; + } + } else if (scheme != QStringLiteral("https")) { + return {}; + } + + // 3. Strip trailing slash from path (preserve subpath segments) + QString path = url.path(); + while (path.endsWith(QLatin1Char('/'))) { + path.chop(1); + } + url.setPath(path, QUrl::DecodedMode); + + // 4. Drop fragment / query -- not part of a server-base URL. + url.setFragment(QString()); + url.setQuery(QString()); + + return url.toString(QUrl::FullyEncoded); +} + +bool NextcloudSyncProvider::isLoopbackHost(const QUrl& url) +{ + const QString host = url.host(QUrl::FullyDecoded); + if (host.isEmpty()) { + return false; + } + // Match the literal hostname "localhost" (case-insensitive). QHostAddress + // does not resolve hostnames, so this exact match is the only way to + // accept the hostname-form. Anything else like "localhost.example.com" is + // NOT loopback and must not match. + if (host.compare(QStringLiteral("localhost"), Qt::CaseInsensitive) == 0) { + return true; + } + // For IP literals, defer to QHostAddress::isLoopback (covers 127.0.0.0/8 + // and ::1). QUrl::host returns IPv6 without surrounding brackets, which + // is the format QHostAddress accepts. + const QHostAddress addr(host); + return !addr.isNull() && addr.isLoopback(); +} + +// --------------------------------------------------------------------------- +// validateServerUrl -- single source of truth for "is this user-typed URL +// acceptable as a Nextcloud server base?". Mirrors canonicalizeServerBaseUrl's +// gates but returns a 4-way enum so callers can dispatch per-case banners +// instead of collapsing every failure to a single generic message. +// +// Order of checks reflects the precision of the message a caller can emit: +// 1. Empty -> "URL field blank" (Warning) +// 2. NotSecure -> "Plain HTTP only for loopback ..." (Error, anti-cleartext) +// 3. Malformed -> "Invalid URL" (Warning) +// 4. Ok -> proceed; canonicalOut filled +// +// Steps 2 and 3 only run when input is non-empty. Step 2 fires BEFORE step 3 +// so a user who typed "http://example.com" sees the actionable cleartext- +// policy message rather than the generic malformed-fallback (their URL is +// syntactically fine -- it's the scheme that the policy rejects). +// --------------------------------------------------------------------------- +NextcloudSyncProvider::ServerUrlValidity +NextcloudSyncProvider::validateServerUrl(const QString& input, QString* canonicalOut) +{ + const QString trimmed = input.trimmed(); + if (trimmed.isEmpty()) { + return ServerUrlValidity::Empty; + } + + // Default scheme to https:// when absent (mirrors canonicalizeServerBaseUrl). + QUrl url(trimmed); + if (url.scheme().isEmpty()) { + url = QUrl(QStringLiteral("https://") + trimmed); + } + + // Transport-security gate. NotSecure is reported BEFORE Malformed so the + // user who typed a syntactically-valid http://example.com sees the + // specific cleartext-policy banner rather than a generic "invalid URL". + const QString scheme = url.scheme(); + if (scheme == QStringLiteral("http")) { + if (!isLoopbackHost(url)) { + return ServerUrlValidity::NotSecure; + } + } else if (scheme != QStringLiteral("https")) { + return ServerUrlValidity::Malformed; + } + + // Syntax + host gate. QUrl::isValid is permissive (e.g. accepts + // "https://" with empty host). We require a non-empty host so inputs + // like "https://" or "https:///foo" -- which would canonicalize to + // something that fails only at request-issue time downstream -- are + // caught here at the trust boundary. + if (!url.isValid() || url.host().isEmpty()) { + return ServerUrlValidity::Malformed; + } + + if (canonicalOut) { + *canonicalOut = canonicalizeServerBaseUrl(trimmed); + } + return ServerUrlValidity::Ok; +} + +// --------------------------------------------------------------------------- +// Remote-path NFC normalization helper. +// Nextcloud's WebDAV layer treats the path as a byte sequence -- a decomposed +// form (NFD, e.g. macOS Finder paste) won't match a server-stored NFC path +// for the same logical filename. Normalize to NFC at the UI boundary so the +// "NFC-normalized at save" contract in RemoteSyncParams.h holds in practice. +// Idempotent. +// --------------------------------------------------------------------------- +QString NextcloudSyncProvider::normalizeRemotePath(QString input) +{ + return input.trimmed().normalized(QString::NormalizationForm_C); +} + +// --------------------------------------------------------------------------- +// WebDAV path composition helper. +// Uses QUrl::setPath(decoded, QUrl::DecodedMode) for correct per-segment +// encoding -- avoids double-encoding of spaces, parens, non-ASCII, '@' etc. +// --------------------------------------------------------------------------- +QUrl NextcloudSyncProvider::buildResourceUrl(const QString& canonicalBase, + const QString& loginName, + const QString& remotePath) +{ + QUrl url(canonicalBase); + + // Per-segment encoding for the loginName (Qt's path encoder treats '@' as + // a valid pchar and won't escape it, but Nextcloud expects '%40' here). + // The remotePath is left as raw decoded characters so its '/' separators + // survive. TolerantMode preserves the loginName's existing %-encoding + // while still encoding spaces, non-ASCII, etc. in remotePath. + const QString encodedLogin = QString::fromUtf8(QUrl::toPercentEncoding(loginName)); + QString fullPath = url.path() // preserves subpath if present + + QStringLiteral("/remote.php/dav/files/") + encodedLogin + + (remotePath.startsWith(QLatin1Char('/')) ? remotePath : QStringLiteral("/") + remotePath); + + url.setPath(fullPath, QUrl::TolerantMode); + return url; +} diff --git a/src/remotesync/NextcloudSyncProvider.h b/src/remotesync/NextcloudSyncProvider.h new file mode 100644 index 0000000000..364cb14e95 --- /dev/null +++ b/src/remotesync/NextcloudSyncProvider.h @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_NEXTCLOUDSYNCPROVIDER_H +#define KEEPASSXC_NEXTCLOUDSYNCPROVIDER_H + +#include "RemoteSyncProvider.h" + +#include +#include +#include +#include + +#include + +class QJsonObject; +class QNetworkAccessManager; +class QNetworkReply; +struct NextcloudSyncParams; + +/** + * Nextcloud WebDAV provider: PUT/GET/PROPFIND with ETag-based conflict detection. + * Authenticates via long-lived app password (Basic auth); single-401-retry policy + * routes transient auth failures through retryOnAuthOnce before surfacing. + */ +class NextcloudSyncProvider : public RemoteSyncProvider +{ + Q_OBJECT + +public: + explicit NextcloudSyncProvider(QObject* parent = nullptr); + ~NextcloudSyncProvider() override; + + RemoteHandler::RemoteResult download(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) override; + void abort() override; + + // PROPFIND Depth:0 against the configured remote path. Returns success on + // 200/207, mapWebdavStatusToMessage banner on any other status. + // + // virtual so MockNextcloudSyncProvider can return canned outcomes without + // running the QEventLoop / WebDAV machinery. Mirrors MockDropboxSyncProvider + // overrides of the four network-fronted operations. + virtual RemoteHandler::RemoteResult testConnection(const NextcloudSyncParams* params); + + QString displayName() const override; + RemoteSyncParams* createParams() const override; + RemoteSyncParams* buildParamsFromConfig(const QJsonObject& config) const override; + ErrorKind classifyError(const QString& errorMessage) const override; + bool isAuthorized(const QJsonObject& config) const override; + + // Inject a QNetworkAccessManager for testing (mock QNAM whose + // get/put/sendCustomRequest returns MockNetworkReply). Caller retains + // ownership: the injected NAM must outlive this object, is never + // delete-d or reparented by the setter, and calling with nullptr does + // not free a previously-set NAM. + void setNetworkAccessManager(QNetworkAccessManager* nam); + + // Public static URL helpers -- pure functions, no side effects, called by + // both this provider and the settings widget. Idempotent. + // + // canonicalizeServerBaseUrl also enforces the transport security policy: + // an empty string is returned for any URL that would let the Basic-auth + // app-password header leave the box unprotected. Concretely: + // - missing scheme: defaulted to https + // - https: accepted + // - http with a loopback host (localhost / 127.x / ::1): accepted + // - http with any other host: REJECTED (returns "") + // - anything else (ftp/file/etc.): REJECTED + // Callers should pre-validate at the user-input boundary to surface a + // specific error; an empty return here is the fail-closed fallback. + static QString canonicalizeServerBaseUrl(QString input); + static QUrl buildResourceUrl(const QString& canonicalBase, const QString& loginName, const QString& remotePath); + + // Returns true iff the URL's host is a loopback address (the literal + // "localhost", any 127.x.x.x IPv4, or ::1 IPv6). Used by the transport- + // security gate in canonicalizeServerBaseUrl AND by the page so the + // settings UI surfaces a specific error before a save / authorize + // attempts to send creds. + static bool isLoopbackHost(const QUrl& url); + + // Outcome of validateServerUrl. Lets the page-side dispatch per-case + // user-facing banners (empty field vs unsupported scheme vs insecure + // scheme vs syntactically malformed URL) rather than collapsing all + // failures to one generic message. + enum class ServerUrlValidity + { + Ok, // non-empty, scheme allowed, host present + Empty, // input.trimmed().isEmpty() + NotSecure, // http scheme with a non-loopback host + Malformed, // unsupported scheme, syntactically invalid, or no host + }; + + // Validate a user-typed Nextcloud server URL against the transport- + // security policy AND the syntactic shape downstream callers (Login Flow + // v2 initiate, WebDAV PROPFIND, browser deep-link) require. Pure + // function -- no side effects, no signal emission. Idempotent. + // + // canonicalOut: optional. When non-null AND result == Ok, receives the + // canonicalizeServerBaseUrl form. Left untouched on any non-Ok result so + // a caller can pass &m_config-bound storage without risking partial fill. + // + // Intentionally does NOT emit showMessage: the 3 click handlers that + // consume this surface different banner palettes (Warning for the + // "fill the field" prompts, Error for the cleartext-policy rejection). + // Centralizing emission here would couple this primitive to the UI. + static ServerUrlValidity validateServerUrl(const QString& input, QString* canonicalOut = nullptr); + + // NFC-normalize + trim a Nextcloud remote path at the UI boundary. + // Nextcloud's WebDAV layer is case-/byte-sensitive on the path, so a + // decomposed (NFD) sequence pasted by the user (e.g. macOS Finder) will + // not match a path stored on the server as NFC. Applying NormalizationForm_C + // at the page-to-config boundary makes the NFC contract on + // NextcloudSyncParams::remotePath actually hold. Idempotent. + static QString normalizeRemotePath(QString input); + + Q_DISABLE_COPY(NextcloudSyncProvider) + +#ifdef QT_TEST_LIB + // Test-only inspection of the captured ETag. Production code never reads + // this -- it is consumed only by upload(). Surfaced here so unit tests can + // assert byte-for-byte preservation of opaque-tag quotes without leaking + // m_lastETag into the public API. +public: + QString lastETagForTest() const + { + return m_lastETag; + } + + // Weak-ETag session-flag accessor for tests. The flag is "sticky" within a + // single provider lifetime (once a W/"..." is observed, all subsequent + // uploads silent-overwrite). Tests assert the flag is set after the first + // weak detection and stays set through subsequent strong ETags. + bool serverEmitsWeakETagsForTest() const + { + return m_serverEmitsWeakETags; + } + + // Seed m_lastETag for upload-side tests that need to assert + // `If-Match: ` round-trip without first running a download. + void setLastETagForTest(const QString& v) + { + m_lastETag = v; + } + + // Seed m_serverEmitsWeakETags for upload-side tests that need to assert + // silent-overwrite semantics (NEITHER If-Match NOR If-None-Match: * sent) + // without first running a weak-ETag download. + void setServerEmitsWeakETagsForTest(bool v) + { + m_serverEmitsWeakETags = v; + } + + // Seed m_abortFlag so a test can assert the entry-point reset contract + // (the flag must be cleared at the top of download() / upload() / + // testConnection() / refreshAuth() before any HTTP work). + void setAbortFlagForTest(int v) + { + m_abortFlag.storeRelease(v); + } + + // Read m_abortFlag without taking the reply mutex. Tests use this to + // assert abort() set the flag. Production code never reads this. + int abortFlagForTest() const + { + return m_abortFlag.loadAcquire(); + } + + // Shrink the retryOnAuthOnce backoff from the production 2-second default + // to milliseconds so unit tests run in well under 1 second wall-clock per + // test. Production code never calls this; the default 2000ms is set in + // the member initializer below. + void setRetryBackoffMsForTest(int ms) + { + m_retryBackoffMs = ms; + } +#endif + +signals: + // Emitted once per session when a weak ETag (W/"...") is detected from + // the server. + void weakEtagDetected(); + +private: + // Trashbin lookup for the 404 distinction. PROPFIND Depth:1 against + // `/remote.php/dav/trashbin//trash`. Returns true when + // the file's basename appears anywhere in any `` element in the + // response body (permissive contains() match -- handles deletion-suffix + // format variance). Returns false for any failure (no trashbin, network + // error, etc.) -- treats the absence as "first sync" silent success + // rather than an error. + bool checkIfInTrash(const NextcloudSyncParams* params, const QString& filename); + + // Lazy-construct or return the injected QNetworkAccessManager (mirrors + // Dropbox). + void ensureNam(); + + // Centralized HTTP-status -> locked banner-string mapper. Pure function + // of httpStatus (no instance state); called from download(), upload(), + // and testConnection() non-success branches. + static QString mapWebdavStatusToMessage(int httpStatus); + + // Companion to mapWebdavStatusToMessage: the same HTTP status maps to + // a machine-readable ErrorKind that retry/dispatch logic can branch on + // without parsing the (localized) banner string. + static RemoteHandler::ErrorKind mapWebdavStatusToKind(int httpStatus); + + // Single-401-retry-after-backoff policy wrapper. Invokes op() once. If + // the result is success OR classifyError(result.errorMessage) != + // ErrorKind::AuthExpired, returns that first result immediately. + // Otherwise sleeps m_retryBackoffMs -- polling m_abortFlag every 100ms + // so abort() during the backoff returns the first-attempt result early + // without making the second attempt -- then invokes op() once more and + // returns that second result. + // + // First 401 NEVER mutates params or provider state. Stored creds in + // CustomData are NEVER auto-wiped here -- the helper only retries. Two + // consecutive 401s surface the standard 401 banner (or, on the manual- + // paste credential-validation path through testConnection, the credential- + // rejection banner); the user action is the same in both cases + // ("Re-authorize" or "Verify creds"). + RemoteHandler::RemoteResult retryOnAuthOnce(std::function op); + + // Implementation bodies. The public download() / upload() / + // testConnection() entry points are thin wrappers around + // retryOnAuthOnce([this, params]() { return *Impl(...); }); the actual + // transport logic lives here. The abort-flag reset + // (m_abortFlag.storeRelease(0)) is performed in each public wrapper so a + // fresh op call always starts with a clean abort flag. + RemoteHandler::RemoteResult downloadImpl(const RemoteSyncParams* params); + RemoteHandler::RemoteResult uploadImpl(const QString& filePath, const RemoteSyncParams* params); + RemoteHandler::RemoteResult testConnectionImpl(const NextcloudSyncParams* params); + + QNetworkAccessManager* m_nam = nullptr; + + // HTTP status constants for WebDAV operations. + static constexpr int HttpOk = 200; + static constexpr int HttpCreated = 201; + static constexpr int HttpNoContent = 204; + static constexpr int HttpMultiStatus = 207; + static constexpr int HttpUnauthorized = 401; + static constexpr int HttpForbidden = 403; + static constexpr int HttpNotFound = 404; + static constexpr int HttpPreconditionFailed = 412; + static constexpr int HttpLocked = 423; + static constexpr int HttpInsufficientStorage = 507; + static constexpr int MaxDatabaseSize = 256 * 1024 * 1024; // 256 MB sanity limit + + QString m_lastETag; // ETag from last download, used for upload If-Match + bool m_serverEmitsWeakETags = false; // Sticky session flag once a W/"..." is seen + QNetworkReply* m_activeReply = nullptr; // For abort support + mutable QMutex m_replyMutex; // Protects m_activeReply across threads + QAtomicInt m_abortFlag; // Atomic flag checked by HttpRetryHelper between retries + int m_retryBackoffMs = 2000; // 2-second backoff between first-401 and retry attempt; + // setRetryBackoffMsForTest shrinks this to ms in unit tests. +}; + +#endif // KEEPASSXC_NEXTCLOUDSYNCPROVIDER_H diff --git a/src/remotesync/OAuthHttpServer.cpp b/src/remotesync/OAuthHttpServer.cpp new file mode 100644 index 0000000000..4fc4fd75c0 --- /dev/null +++ b/src/remotesync/OAuthHttpServer.cpp @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "OAuthHttpServer.h" + +#include +#include +#include +#include + +namespace +{ + const char* successHtml = "" + "

Authorization successful.

" + "

You can close this tab and return to KeePassXC.

" + ""; + + const char* errorHtml = "" + "

Authorization failed.

" + "

Error: %1

" + "

Please close this tab and try again in KeePassXC.

" + ""; + + const char* invalidRequestHtml = "" + "

Invalid request.

" + ""; + + QByteArray buildHttpResponse(int statusCode, const QString& statusText, const QString& body) + { + QByteArray response; + response.append(QStringLiteral("HTTP/1.1 %1 %2\r\n").arg(statusCode).arg(statusText).toUtf8()); + response.append("Content-Type: text/html; charset=utf-8\r\n"); + response.append("Connection: close\r\n"); + response.append("\r\n"); + response.append(body.toUtf8()); + return response; + } +} // namespace + +OAuthHttpServer::OAuthHttpServer(QObject* parent) + : QObject(parent) + , m_server(new QTcpServer(this)) +{ + connect(m_server, &QTcpServer::newConnection, this, &OAuthHttpServer::handleNewConnection); +} + +OAuthHttpServer::~OAuthHttpServer() +{ + stop(); +} + +bool OAuthHttpServer::start(quint16 port) +{ + m_codeReceived = false; + return m_server->listen(QHostAddress::LocalHost, port); +} + +quint16 OAuthHttpServer::port() const +{ + return m_server->serverPort(); +} + +void OAuthHttpServer::stop() +{ + m_server->close(); + m_codeReceived = false; + m_expectedState.clear(); + + // Close any still-connected sockets to prevent dangling lambda captures + const auto sockets = m_server->findChildren(); + for (auto* socket : sockets) { + socket->disconnectFromHost(); + } +} + +void OAuthHttpServer::setExpectedState(const QString& state) +{ + m_expectedState = state; +} + +bool OAuthHttpServer::isListening() const +{ + return m_server->isListening(); +} + +void OAuthHttpServer::handleNewConnection() +{ + while (m_server->hasPendingConnections()) { + auto* socket = m_server->nextPendingConnection(); + if (!socket) { + continue; + } + + // Per-socket read timeout to prevent slowloris-style attacks + QTimer::singleShot(SocketTimeoutMs, socket, [socket]() { + if (socket->state() != QTcpSocket::UnconnectedState) { + socket->disconnectFromHost(); + } + }); + + // Accumulate data until we have complete HTTP headers + connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { + if (socket->bytesAvailable() > 0) { + auto data = socket->property("_httpBuffer").toByteArray(); + data.append(socket->readAll()); + + // Reject oversized requests to prevent unbounded memory usage + if (data.size() > MaxRequestSize) { + socket->write(buildHttpResponse(413, "Payload Too Large", invalidRequestHtml)); + socket->flush(); + // Disconnect after the write buffer is flushed to the client + connect(socket, &QTcpSocket::bytesWritten, socket, [socket]() { + if (socket->bytesToWrite() == 0) { + socket->disconnectFromHost(); + } + }); + return; + } + + socket->setProperty("_httpBuffer", data); + + if (data.contains("\r\n\r\n")) { + processRequest(socket); + } + } + }); + + // Clean up socket on disconnect + connect(socket, &QTcpSocket::disconnected, socket, &QTcpSocket::deleteLater); + } +} + +void OAuthHttpServer::processRequest(QTcpSocket* socket) +{ + auto requestData = socket->property("_httpBuffer").toByteArray(); + auto requestLine = QString::fromUtf8(requestData.left(requestData.indexOf("\r\n"))); + + // Parse the request line: "GET /path?query HTTP/1.1" + auto parts = requestLine.split(' '); + if (parts.size() < 2) { + socket->write(buildHttpResponse(400, "Bad Request", invalidRequestHtml)); + socket->flush(); + socket->disconnectFromHost(); + return; + } + + auto urlStr = parts.at(1); + QUrl url(urlStr); + QUrlQuery query(url.query()); + + // Only process if we have a code or error parameter + QString code = query.queryItemValue("code"); + QString error = query.queryItemValue("error"); + + if (code.isEmpty() && error.isEmpty()) { + // Stray browser request (e.g. favicon.ico): respond 400 and close. + socket->write(buildHttpResponse(400, "Bad Request", invalidRequestHtml)); + socket->flush(); + socket->disconnectFromHost(); + return; + } + + // Validate OAuth state parameter for CSRF protection (RFC 6749 §10.12) + if (!m_expectedState.isEmpty()) { + QString receivedState = query.queryItemValue("state"); + if (receivedState != m_expectedState) { + socket->write(buildHttpResponse( + 403, + "Forbidden", + QString(errorHtml).arg(QStringLiteral("State mismatch - possible CSRF attack").toHtmlEscaped()))); + socket->flush(); + socket->disconnectFromHost(); + emit authError(QStringLiteral("state_mismatch")); + return; + } + } + + // Prevent double-processing + if (m_codeReceived) { + socket->write(buildHttpResponse(200, "OK", successHtml)); + socket->flush(); + socket->disconnectFromHost(); + return; + } + + m_codeReceived = true; + + if (!code.isEmpty()) { + socket->write(buildHttpResponse(200, "OK", successHtml)); + socket->flush(); + socket->disconnectFromHost(); + emit authCodeReceived(code); + } else { + auto errorDescription = query.queryItemValue("error_description").replace('+', ' '); + auto errorMsg = errorDescription.isEmpty() ? error : errorDescription; + socket->write(buildHttpResponse(200, "OK", QString(errorHtml).arg(errorMsg.toHtmlEscaped()))); + socket->flush(); + socket->disconnectFromHost(); + emit authError(error); + } +} diff --git a/src/remotesync/OAuthHttpServer.h b/src/remotesync/OAuthHttpServer.h new file mode 100644 index 0000000000..f2cf354393 --- /dev/null +++ b/src/remotesync/OAuthHttpServer.h @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_OAUTHHTTPSERVER_H +#define KEEPASSXC_OAUTHHTTPSERVER_H + +#include +#include + +class QTcpSocket; + +class OAuthHttpServer : public QObject +{ + Q_OBJECT +public: + explicit OAuthHttpServer(QObject* parent = nullptr); + ~OAuthHttpServer() override; + + // Start listening. port=0 means OS-assigned. + // Returns true if server started successfully. + bool start(quint16 port = 0); + + // The actual port the server is listening on (after start). + quint16 port() const; + + // Stop listening and clean up connected sockets. + void stop(); + + // Whether the server is currently listening. + bool isListening() const; + + // Set expected OAuth state parameter for CSRF validation. + // If set, the callback must include a matching state= query parameter. + void setExpectedState(const QString& state); + +signals: + void authCodeReceived(const QString& code); + void authError(const QString& error); + +private slots: + void handleNewConnection(); + +private: + void processRequest(QTcpSocket* socket); + + static constexpr int MaxRequestSize = 8192; // 8KB limit for HTTP request headers + static constexpr int SocketTimeoutMs = 10000; // 10s per-socket read timeout + + QTcpServer* m_server = nullptr; + bool m_codeReceived = false; // Prevents double-processing + QString m_expectedState; +}; + +#endif // KEEPASSXC_OAUTHHTTPSERVER_H diff --git a/src/remotesync/RemoteSyncParams.h b/src/remotesync/RemoteSyncParams.h new file mode 100644 index 0000000000..71dda9309f --- /dev/null +++ b/src/remotesync/RemoteSyncParams.h @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REMOTESYNCPARAMS_H +#define KEEPASSXC_REMOTESYNCPARAMS_H + +#include +#include + +struct RemoteSyncParams +{ + QString type; // "command", "dropbox", etc. + QString name; // User-visible name + + virtual ~RemoteSyncParams() = default; +}; + +struct CommandSyncParams : public RemoteSyncParams +{ + QString downloadCommand; + QString downloadInput; + int downloadTimeoutMsec = 10000; + QString uploadCommand; + QString uploadInput; + int uploadTimeoutMsec = 10000; +}; + +struct DropboxSyncParams : public RemoteSyncParams +{ + QString accessToken; // OAuth2 bearer token (short-lived, ~4 hours) + QString refreshToken; // Long-lived refresh token (does not expire unless revoked) + QDateTime expiresAt; // UTC time when accessToken expires + QString appKey; // User's Dropbox App Key (client_id for PKCE) + QString remotePath; // e.g., "/Apps/KeePassXC/passwords.kdbx" + int timeoutMsec = 30000; // Network timeout in milliseconds. +}; + +struct NextcloudSyncParams : public RemoteSyncParams +{ + QString serverBaseUrl; // Canonicalized: no trailing slash, scheme defaulted to https://, subpath preserved + QString remotePath; // NFC-normalized at save; e.g. "/Passwords/db.kdbx" -- must start with '/' + QString loginName; // Nextcloud account login; populated by Login Flow v2 or paste fallback + QString appPassword; // Basic-auth password; populated by Login Flow v2 or paste fallback + int timeoutMsec = 30000; // Network timeout in milliseconds. +}; + +#endif // KEEPASSXC_REMOTESYNCPARAMS_H diff --git a/src/remotesync/RemoteSyncProvider.cpp b/src/remotesync/RemoteSyncProvider.cpp new file mode 100644 index 0000000000..1b4514a3e4 --- /dev/null +++ b/src/remotesync/RemoteSyncProvider.cpp @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "RemoteSyncProvider.h" + +#include "config-keepassx.h" + +#include "CommandSyncProvider.h" +#ifdef KPXC_FEATURE_NETWORK +#include "DropboxSyncProvider.h" +#include "NextcloudSyncProvider.h" +#endif + +#ifdef QT_TEST_LIB +#include "tests/mock/MockSyncProvider.h" +#endif + +#include +#include + +namespace +{ + RemoteSyncProvider::FactoryOverride& factoryOverride() + { + static RemoteSyncProvider::FactoryOverride instance; + return instance; + } +} // namespace + +RemoteSyncProvider::RemoteSyncProvider(QObject* parent) + : QObject(parent) +{ +} + +void RemoteSyncProvider::setFactoryOverrideForTest(FactoryOverride factory) +{ + factoryOverride() = std::move(factory); +} + +void RemoteSyncProvider::clearFactoryOverrideForTest() +{ + factoryOverride() = nullptr; +} + +RemoteSyncParams* RemoteSyncProvider::buildParamsFromConfig(const QJsonObject& config) const +{ + // Default: return a bare-allocated params struct of the correct subclass with + // no fields populated. Providers SHOULD override to lift their type-specific + // fields out of the persisted config. Caller takes ownership. + Q_UNUSED(config) + return createParams(); +} + +bool RemoteSyncProvider::applyRefreshedTokens(const QString& stdOutput, RemoteSyncParams* params) +{ + // Default: no-op success. Providers without token refresh (e.g. command, app-password + // providers) inherit this; providers with OAuth-style refresh override. + Q_UNUSED(stdOutput) + Q_UNUSED(params) + return true; +} + +RemoteSyncProvider::ErrorKind RemoteSyncProvider::classifyError(const QString& errorMessage) const +{ + // Default: Other -- no false positives from accidental keyword matches. + // Providers MUST override to surface auth/network/etc. categories. + Q_UNUSED(errorMessage) + return ErrorKind::Other; +} + +bool RemoteSyncProvider::isAuthorized(const QJsonObject& config) const +{ + // Default: not authorized. Providers override to declare their auth shape. + Q_UNUSED(config) + return false; +} + +void RemoteSyncProvider::persistRefreshedTokens(const QString& stdOutput, + const QString& configKey, + RemoteSettings* settings) const +{ + // Default: no-op. Only providers that issue refreshable tokens override. + Q_UNUSED(stdOutput) + Q_UNUSED(configKey) + Q_UNUSED(settings) +} + +RemoteSyncProvider* RemoteSyncProvider::create(const QString& type, QObject* parent) +{ + if (const auto& override = factoryOverride()) { + if (auto* p = override(type, parent)) { + return p; + } + // Override returned nullptr -> fall through to default dispatch so a + // test that only mocks "dropbox" still gets a real CommandSyncProvider + // for other types. + } + + if (type == QStringLiteral("command")) { + return new CommandSyncProvider(parent); + } +#ifdef KPXC_FEATURE_NETWORK + if (type == QStringLiteral("dropbox")) { + return new DropboxSyncProvider(parent); + } + if (type == QStringLiteral("nextcloud")) { + return new NextcloudSyncProvider(parent); + } +#endif +#ifdef QT_TEST_LIB + if (type == QStringLiteral("mock")) { + return new MockSyncProvider(parent); + } +#endif + qWarning("RemoteSyncProvider: Unknown provider type '%s'", qPrintable(type)); + return nullptr; +} diff --git a/src/remotesync/RemoteSyncProvider.h b/src/remotesync/RemoteSyncProvider.h new file mode 100644 index 0000000000..a42f956562 --- /dev/null +++ b/src/remotesync/RemoteSyncProvider.h @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REMOTESYNCPROVIDER_H +#define KEEPASSXC_REMOTESYNCPROVIDER_H + +#include + +#include + +#include "gui/remote/RemoteHandler.h" // For RemoteHandler::RemoteResult + +class QJsonObject; +class RemoteSettings; +struct RemoteSyncParams; + +class RemoteSyncProvider : public QObject +{ + Q_OBJECT + +public: + /// Alias for RemoteHandler::ErrorKind, which is the canonical definition + /// (carried on RemoteHandler::RemoteResult). + using ErrorKind = RemoteHandler::ErrorKind; + + explicit RemoteSyncProvider(QObject* parent = nullptr); + ~RemoteSyncProvider() override = default; + + // Core sync operations -- synchronous, blocking + virtual RemoteHandler::RemoteResult download(const RemoteSyncParams* params) = 0; + virtual RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) = 0; + + // Auth refresh -- no-op for command provider, real for OAuth providers + virtual RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) = 0; + + // Cancel support -- abort in-flight operation + virtual void abort() = 0; + + /// Untranslated provider identifier shown in user-visible chrome. + /// E.g. "Dropbox", "Nextcloud". UI applies tr() at the call site. + virtual QString displayName() const = 0; + + /// Allocate a fresh RemoteSyncParams subclass for this provider. + /// Caller takes ownership. + virtual RemoteSyncParams* createParams() const = 0; + + /// Build a fresh RemoteSyncParams from a persisted config object. + /// Used by orchestration code that has the JSON config but no knowledge + /// of the concrete params subclass. Default impl: calls createParams() + /// and returns it without populating any fields. Providers should override + /// to populate their type-specific fields. Caller takes ownership. + virtual RemoteSyncParams* buildParamsFromConfig(const QJsonObject& config) const; + + /// Apply refreshed-token JSON (from refreshAuth's stdOutput) to in-memory params. + /// Returns false on parse failure (engine treats as AuthExpired and surfaces banner). + /// Default: no-op true (providers without token refresh inherit). + virtual bool applyRefreshedTokens(const QString& stdOutput, RemoteSyncParams* params); + + /// Classify a provider error message into an ErrorKind for UI dispatch. + /// Default: Other (no false positives from accidental keyword matches). + virtual ErrorKind classifyError(const QString& errorMessage) const; + + /// Returns true if the given config blob contains every field this + /// provider needs to perform an authorized sync. Each provider knows + /// its own auth shape. Callers use this through the abstraction + /// rather than peeking at provider-specific config keys directly. + /// Default: false (fail-closed; providers must opt in). + virtual bool isAuthorized(const QJsonObject& config) const; + + /// Persist refreshed tokens back to RemoteSettings under (type, configKey). + /// Called by orchestration code after refreshAuth succeeds. Default: no-op. + virtual void + persistRefreshedTokens(const QString& stdOutput, const QString& configKey, RemoteSettings* settings) const; + + // Factory method -- returns correct subclass from config type string + // Returns nullptr for unknown types + static RemoteSyncProvider* create(const QString& type, QObject* parent = nullptr); + + /// Test seam: when set, create() routes through this factory instead of + /// the default dispatch, letting tests substitute mock providers so no + /// real network calls are issued. Returning nullptr from the override + /// falls back to the default behavior. + using FactoryOverride = std::function; + static void setFactoryOverrideForTest(FactoryOverride factory); + static void clearFactoryOverrideForTest(); + + Q_DISABLE_COPY(RemoteSyncProvider) +}; + +#endif // KEEPASSXC_REMOTESYNCPROVIDER_H diff --git a/src/remotesync/SyncEngine.cpp b/src/remotesync/SyncEngine.cpp new file mode 100644 index 0000000000..ceacb1a850 --- /dev/null +++ b/src/remotesync/SyncEngine.cpp @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "SyncEngine.h" + +#include + +#include "core/Database.h" +#include "core/Merger.h" +#include "remotesync/RemoteSyncParams.h" +#include "remotesync/RemoteSyncProvider.h" + +SyncEngine::SyncEngine(QSharedPointer db, SaveFn saveFn, QObject* parent) + : QObject(parent) + , m_db(std::move(db)) + , m_saveFn(std::move(saveFn)) +{ + Q_ASSERT(m_saveFn); +} + +SyncEngine::~SyncEngine() +{ + // Safety net: clean up any remaining temp file + if (!m_downloadedFilePath.isEmpty()) { + QFile::remove(m_downloadedFilePath); + m_downloadedFilePath.clear(); + } +} + +SyncEngine::State SyncEngine::state() const +{ + return m_state; +} + +QString SyncEngine::downloadedFilePath() const +{ + return m_downloadedFilePath; +} + +RemoteHandler::ErrorKind SyncEngine::lastErrorKind() const +{ + return m_lastErrorKind; +} + +bool SyncEngine::startSync(RemoteSyncProvider* provider, RemoteSyncParams* params) +{ + if (m_state != State::Idle) { + emit syncError(tr("Sync already in progress.")); + return false; + } + + m_provider = provider; + m_params = params; + m_cancelRequested = false; + m_downloadedFilePath.clear(); + m_changeList.clear(); + m_lastErrorKind = RemoteHandler::ErrorKind::Other; + + doAuthenticate(); + return true; +} + +void SyncEngine::cancel() +{ + if (m_state == State::Idle) { + return; + } + m_cancelRequested = true; +} + +void SyncEngine::setState(State newState) +{ + if (m_state == newState) { + return; + } + m_state = newState; + emit stateChanged(m_state); +} + +void SyncEngine::doAuthenticate() +{ + setState(State::Authenticating); + emit syncProgress(10, tr("Refreshing authentication...")); + + auto result = m_provider->refreshAuth(m_params); + if (!result.success) { + qWarning("[SyncEngine] refreshAuth FAILED: %s", qPrintable(result.errorMessage)); + m_lastErrorKind = result.kind; + handleError(result.errorMessage); + return; + } + + // If refreshAuth returned updated token data, apply it to in-memory + // params. A parse/apply failure surfaces as an authentication error. + if (!result.stdOutput.isEmpty()) { + if (!m_provider->applyRefreshedTokens(result.stdOutput, m_params)) { + handleError(tr("Authentication expired. Re-authorize in Database > Settings > Cloud Sync.")); + return; + } + emit refreshedTokenData(result.stdOutput); + } + + if (m_cancelRequested) { + cleanup(); + emit syncFinished(false, tr("Sync cancelled.")); + return; + } + + doDownload(); +} + +void SyncEngine::doDownload() +{ + setState(State::Downloading); + emit syncProgress(25, tr("Downloading remote database...")); + + auto result = m_provider->download(m_params); + if (!result.success) { + qWarning("[SyncEngine] download FAILED: %s", qPrintable(result.errorMessage)); + m_lastErrorKind = result.kind; + handleError(result.errorMessage); + return; + } + + m_downloadedFilePath = result.filePath; + + if (m_cancelRequested) { + cleanup(); + emit syncFinished(false, tr("Sync cancelled.")); + return; + } + + // First-sync convention: providers return {success=true, filePath=""} + // when the remote file doesn't exist yet (e.g. Dropbox 404 + // path/not_found, Nextcloud first-sync). Skip merge in that case -- + // there's nothing to merge against -- and go straight to the local + // save + upload that creates the file on the remote side. + if (m_downloadedFilePath.isEmpty()) { + doSave(); + return; + } + + doMerge(); +} + +void SyncEngine::doMerge() +{ + setState(State::Merging); + emit syncProgress(50, tr("Merging databases...")); + + QSharedPointer remoteDb = QSharedPointer::create(); + QString error; + bool opened = remoteDb->open(m_downloadedFilePath, m_db->key(), &error); + if (!opened) { + // Fallback: if the user just changed the master key, the remote + // still holds the old one. Database::syncPreviousKey() returns the + // snapshot captured at change-key time; retry with that. doUpload + // clears the snapshot on success so the remote is migrated. + auto previousKey = m_db->syncPreviousKey(); + if (previousKey) { + opened = remoteDb->open(m_downloadedFilePath, previousKey, &error); + } + } + if (!opened) { + // Remote DB needs a different key. Hand off the temp file to the + // receiver -- DatabaseWidget keeps it alive across the unlock + // dialog (which needs to read it back) and removes it when the + // dialog completes. Clearing m_downloadedFilePath here also stops + // the engine destructor from racing the receiver to delete it. + QString filePath = m_downloadedFilePath; + m_downloadedFilePath.clear(); + m_cancelRequested = false; + m_provider = nullptr; + m_params = nullptr; + setState(State::Idle); + emit remoteDbNeedsKey(filePath); + return; + } + remoteDb->markAsTemporaryDatabase(); + + // One-way merge: remote INTO local + Merger merger(remoteDb.data(), m_db.data()); + m_changeList = merger.merge(); + + if (m_cancelRequested) { + // Rollback: re-open from disk to restore pre-merge in-memory state + m_db->open(m_db->filePath(), m_db->key()); + cleanup(); + emit syncFinished(false, tr("Sync cancelled.")); + return; + } + + doSave(); +} + +void SyncEngine::doSave() +{ + setState(State::Saving); + emit syncProgress(65, tr("Saving local database...")); + + QString error; + if (!m_saveFn(error)) { + qWarning("[SyncEngine] save FAILED: %s", qPrintable(error)); + handleError(tr("Failed to save local database: %1").arg(error)); + return; + } + + if (m_cancelRequested) { + cleanup(); + emit syncFinished(false, tr("Sync cancelled.")); + return; + } + + doUpload(); +} + +void SyncEngine::doUpload() +{ + setState(State::Uploading); + emit syncProgress(85, tr("Uploading merged database...")); + + // Upload the LOCAL .kdbx file (not the downloaded temp file) + auto result = m_provider->upload(m_db->filePath(), m_params); + cleanup(); // Always cleanup temp files + + if (!result.success) { + qWarning("[SyncEngine] upload FAILED: %s", qPrintable(result.errorMessage)); + m_lastErrorKind = result.kind; + // Upload failed, but the local save already succeeded -- no rollback. + emit syncFinished(false, tr("Upload failed: %1").arg(result.errorMessage)); + return; + } + + // Remote now holds the same master key as the local DB; the change-key + // snapshot (if any) is no longer needed. + m_db->clearSyncPreviousKey(); + + emit syncProgress(100, tr("Sync complete.")); + emit syncFinished(true, QString()); +} + +void SyncEngine::handleError(const QString& errorMessage) +{ + cleanup(); + emit syncFinished(false, errorMessage); +} + +void SyncEngine::cleanup() +{ + // Remove downloaded temp file if it exists + if (!m_downloadedFilePath.isEmpty()) { + QFile::remove(m_downloadedFilePath); + m_downloadedFilePath.clear(); + } + + m_cancelRequested = false; + m_provider = nullptr; + m_params = nullptr; + setState(State::Idle); +} diff --git a/src/remotesync/SyncEngine.h b/src/remotesync/SyncEngine.h new file mode 100644 index 0000000000..113338ad6c --- /dev/null +++ b/src/remotesync/SyncEngine.h @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_SYNCENGINE_H +#define KEEPASSXC_SYNCENGINE_H + +#include +#include +#include +#include + +#include "core/Merger.h" +#include "gui/remote/RemoteHandler.h" // for ErrorKind + +class Database; +class RemoteSyncProvider; +struct RemoteSyncParams; + +class SyncEngine : public QObject +{ + Q_OBJECT + +public: + enum class State + { + Idle, + Authenticating, + Downloading, + Merging, + Saving, + Uploading + }; + Q_ENUM(State) + + // Callback that performs the local save and returns true on success, + // writing any failure message into the out-parameter. Supplied by the + // caller (DatabaseWidget) so the merged DB goes through the same save + // policy as a normal user-initiated save -- cloud sync MUST NOT bypass + // save policy. + using SaveFn = std::function; + + explicit SyncEngine(QSharedPointer db, SaveFn saveFn, QObject* parent = nullptr); + ~SyncEngine() override; + + State state() const; + + /// The ErrorKind from the last failed sync step (download / merge / save / + /// upload / refreshAuth). Cleared to Other at the start of each startSync. + /// Cached here so consumers (DatabaseWidget, MainWindow) classify on a + /// machine-readable signal instead of substring-matching tr()'d error + /// strings. + RemoteHandler::ErrorKind lastErrorKind() const; + + // Start a sync operation using the given provider and params. + // The provider must outlive the sync call. SyncEngine does NOT take ownership. + // Returns false if a sync is already in progress. + bool startSync(RemoteSyncProvider* provider, RemoteSyncParams* params); + + // Request cancellation. Cancel is checked between steps (not mid-operation). + void cancel(); + + // Accessor for test verification of temp file cleanup + QString downloadedFilePath() const; + +signals: + void stateChanged(SyncEngine::State newState); + void syncProgress(int percentage, const QString& message); + void syncFinished(bool success, const QString& message); + void syncError(const QString& errorMessage); + void remoteDbNeedsKey(const QString& filePath); + // Emitted when refreshAuth() returns updated token data (JSON in stdOutput). + // Caller should parse and persist to CustomData. + void refreshedTokenData(const QString& tokenDataJson); + +private: + void setState(State newState); + void doAuthenticate(); + void doDownload(); + void doMerge(); + void doSave(); + void doUpload(); + void handleError(const QString& errorMessage); + void cleanup(); + + QSharedPointer m_db; + SaveFn m_saveFn; + RemoteSyncProvider* m_provider = nullptr; // non-owning + RemoteSyncParams* m_params = nullptr; + State m_state = State::Idle; + bool m_cancelRequested = false; + QString m_downloadedFilePath; + Merger::ChangeList m_changeList; + RemoteHandler::ErrorKind m_lastErrorKind = RemoteHandler::ErrorKind::Other; + + Q_DISABLE_COPY(SyncEngine) +}; + +#endif // KEEPASSXC_SYNCENGINE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d11e5660b5..581d7c64e1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -92,6 +92,12 @@ add_unit_test(NAME testautotype SOURCES TestAutoType.cpp LIBS testsupport ${TEST_LIBRARIES}) set_target_properties(testautotype PROPERTIES ENABLE_EXPORTS ON) +add_unit_test(NAME testremotesync SOURCES TestRemoteSync.cpp mock/MockRemoteProcess.cpp + LIBS ${TEST_LIBRARIES}) + +add_unit_test(NAME testsyncengine SOURCES TestSyncEngine.cpp + LIBS testsupport ${TEST_LIBRARIES}) + add_unit_test(NAME testentry SOURCES TestEntry.cpp LIBS ${TEST_LIBRARIES}) @@ -177,8 +183,26 @@ if(KPXC_FEATURE_NETWORK) add_unit_test(NAME testupdatecheck SOURCES TestUpdateCheck.cpp LIBS ${TEST_LIBRARIES}) - add_unit_test(NAME testicondownloader SOURCES TestIconDownloader.cpp + add_unit_test(NAME testicondownloader SOURCES TestIconDownloader.cpp LIBS ${TEST_LIBRARIES}) + + add_unit_test(NAME testhttpretryhelper SOURCES TestHttpRetryHelper.cpp + LIBS ${TEST_LIBRARIES} Qt6::Network) + + add_unit_test(NAME testoauthhttpserver SOURCES TestOAuthHttpServer.cpp + LIBS ${TEST_LIBRARIES} Qt6::Network) + + add_unit_test(NAME testdropboxsyncprovider SOURCES TestDropboxSyncProvider.cpp + LIBS testsupport ${TEST_LIBRARIES} Qt6::Network) + + add_unit_test(NAME testdropboxloginflow SOURCES TestDropboxLoginFlow.cpp + LIBS ${TEST_LIBRARIES} Qt6::Network) + + add_unit_test(NAME testnextcloudsyncprovider SOURCES TestNextcloudSyncProvider.cpp + LIBS ${TEST_LIBRARIES} Qt6::Network) + + add_unit_test(NAME testnextcloudloginflow SOURCES TestNextcloudLoginFlow.cpp + LIBS ${TEST_LIBRARIES} Qt6::Network) endif() if(WITH_GUI_TESTS) diff --git a/tests/TestDatabase.cpp b/tests/TestDatabase.cpp index 6eb2378d98..e56413e256 100644 --- a/tests/TestDatabase.cpp +++ b/tests/TestDatabase.cpp @@ -307,3 +307,30 @@ void TestDatabase::testExternallyModified() // ignoreFileChangesUntilSaved should reset after save QVERIFY(db->ignoreFileChangesUntilSaved() == false); } + +void TestDatabase::testSyncPreviousKey() +{ + auto db = QSharedPointer::create(); + QVERIFY(!db->syncPreviousKey()); + + auto keyA = QSharedPointer::create(); + keyA->addKey(QSharedPointer::create("A")); + + db->setSyncPreviousKey(keyA); + QCOMPARE(db->syncPreviousKey().data(), keyA.data()); + + // No-op while a snapshot is pending: see Database::setSyncPreviousKey. + auto keyB = QSharedPointer::create(); + keyB->addKey(QSharedPointer::create("B")); + db->setSyncPreviousKey(keyB); + QCOMPARE(db->syncPreviousKey().data(), keyA.data()); + + db->clearSyncPreviousKey(); + QVERIFY(!db->syncPreviousKey()); + + db->setSyncPreviousKey(keyB); + QCOMPARE(db->syncPreviousKey().data(), keyB.data()); + + db->releaseData(); + QVERIFY(!db->syncPreviousKey()); +} diff --git a/tests/TestDatabase.h b/tests/TestDatabase.h index e23b23cf8a..e33bb10850 100644 --- a/tests/TestDatabase.h +++ b/tests/TestDatabase.h @@ -37,6 +37,7 @@ private slots: void testEmptyRecycleBinWithHierarchicalData(); void testCustomIcons(); void testExternallyModified(); + void testSyncPreviousKey(); }; #endif // KEEPASSX_TESTDATABASE_H diff --git a/tests/TestDropboxLoginFlow.cpp b/tests/TestDropboxLoginFlow.cpp new file mode 100644 index 0000000000..36f854e536 --- /dev/null +++ b/tests/TestDropboxLoginFlow.cpp @@ -0,0 +1,318 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestDropboxLoginFlow.h" + +#include "crypto/Crypto.h" +#include "remotesync/DropboxLoginFlow.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestDropboxLoginFlow) + +namespace +{ + // The DropboxLoginFlow implementation hardcodes this port for the localhost + // callback. To force manual-fallback in tests we bind a blocker QTcpServer + // to it before driving the flow. + constexpr quint16 kLocalCallbackPort = 12345; +} // namespace + +void TestDropboxLoginFlow::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +// --------------------------------------------------------------------------- +// PKCE pure statics +// --------------------------------------------------------------------------- + +void TestDropboxLoginFlow::testGenerateCodeVerifier_lengthAndCharset() +{ + const QString verifier = DropboxLoginFlow::generateCodeVerifier(); + // RFC 7636 §4.1: code_verifier = high-entropy string, 43..128 chars, + // unreserved char set [A-Z / a-z / 0-9 / "-" / "." / "_" / "~"]. The + // base64url-without-padding encoding the impl uses yields chars from + // [A-Za-z0-9_-], which is a subset. + QVERIFY2(verifier.length() >= 43 && verifier.length() <= 128, + qPrintable(QStringLiteral("verifier length out of RFC range: %1").arg(verifier.length()))); + + const QRegularExpression base64Url(QStringLiteral("^[A-Za-z0-9_-]+$")); + QVERIFY2(base64Url.match(verifier).hasMatch(), + qPrintable(QStringLiteral("verifier contains invalid chars: %1").arg(verifier))); + // Explicit no-padding / no '+' / no '/' assertions (the production + // base64url encoding must use Base64UrlEncoding|OmitTrailingEquals). + QVERIFY(!verifier.contains(QLatin1Char('='))); + QVERIFY(!verifier.contains(QLatin1Char('+'))); + QVERIFY(!verifier.contains(QLatin1Char('/'))); +} + +void TestDropboxLoginFlow::testGenerateCodeVerifier_isRandom() +{ + QSet seen; + for (int i = 0; i < 10; ++i) { + seen.insert(DropboxLoginFlow::generateCodeVerifier()); + } + QCOMPARE(seen.size(), 10); +} + +void TestDropboxLoginFlow::testDeriveCodeChallenge_S256_isDeterministic() +{ + const QString a = DropboxLoginFlow::deriveCodeChallenge(QStringLiteral("known-input")); + const QString b = DropboxLoginFlow::deriveCodeChallenge(QStringLiteral("known-input")); + QCOMPARE(a, b); + QVERIFY(!a.isEmpty()); +} + +void TestDropboxLoginFlow::testDeriveCodeChallenge_S256_matchesRFC7636Vector() +{ + // RFC 7636 Appendix B test vector: + // code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + const QString verifier = QStringLiteral("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + const QString expected = QStringLiteral("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + QCOMPARE(DropboxLoginFlow::deriveCodeChallenge(verifier), expected); +} + +void TestDropboxLoginFlow::testDeriveCodeChallenge_differentInputsProduceDifferentOutputs() +{ + const QString a = DropboxLoginFlow::deriveCodeChallenge(QStringLiteral("a")); + const QString b = DropboxLoginFlow::deriveCodeChallenge(QStringLiteral("b")); + QVERIFY(a != b); +} + +// --------------------------------------------------------------------------- +// State guards on submitManualCode +// --------------------------------------------------------------------------- + +void TestDropboxLoginFlow::testSubmitManualCode_withoutManualFallbackState_emitsFailed() +{ + DropboxLoginFlow flow; + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + + flow.submitManualCode(QStringLiteral("anycode"), 1000); + + QCOMPARE(failedSpy.count(), 1); +} + +void TestDropboxLoginFlow::testSubmitManualCode_emptyCode_emitsFailed() +{ + QTcpServer blocker; + if (!blocker.listen(QHostAddress::LocalHost, kLocalCallbackPort)) { + QSKIP("Cannot bind localhost:12345 — port unavailable on this machine"); + } + + DropboxLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + QSignalSpy fallbackSpy(&flow, &DropboxLoginFlow::authorizationManualFallback); + flow.startAuthorization(QStringLiteral("appkey"), 1000); + QCOMPARE(fallbackSpy.count(), 1); + + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + flow.submitManualCode(QStringLiteral(""), 1000); + + QCOMPARE(failedSpy.count(), 1); + const QString banner = failedSpy.first().at(0).toString(); + QVERIFY2(banner.contains(QStringLiteral("required"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("unexpected banner: %1").arg(banner))); +} + +void TestDropboxLoginFlow::testSubmitManualCode_whitespaceCode_emitsFailed() +{ + QTcpServer blocker; + if (!blocker.listen(QHostAddress::LocalHost, kLocalCallbackPort)) { + QSKIP("Cannot bind localhost:12345 — port unavailable on this machine"); + } + + DropboxLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + QSignalSpy fallbackSpy(&flow, &DropboxLoginFlow::authorizationManualFallback); + flow.startAuthorization(QStringLiteral("appkey"), 1000); + QCOMPARE(fallbackSpy.count(), 1); + + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + flow.submitManualCode(QStringLiteral(" \t\n"), 1000); + + QCOMPARE(failedSpy.count(), 1); +} + +// --------------------------------------------------------------------------- +// startAuthorization guards +// --------------------------------------------------------------------------- + +void TestDropboxLoginFlow::testStartAuthorization_emptyAppKey_emitsFailed() +{ + DropboxLoginFlow flow; + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + QSignalSpy completedSpy(&flow, &DropboxLoginFlow::authorizationCompleted); + + flow.startAuthorization(QStringLiteral(""), 1000); + + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(completedSpy.count(), 0); + const QString banner = failedSpy.first().at(0).toString(); + QVERIFY2(banner.contains(QStringLiteral("App Key"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("unexpected banner: %1").arg(banner))); +} + +void TestDropboxLoginFlow::testStartAuthorization_browserOpenerCalledWithCorrectQuery() +{ + DropboxLoginFlow flow; + QUrl capturedUrl; + bool wasCalled = false; + flow.setBrowserOpener([&capturedUrl, &wasCalled](const QUrl& url) { + capturedUrl = url; + wasCalled = true; + }); + + flow.startAuthorization(QStringLiteral("test-app-key"), 1000); + + QVERIFY(wasCalled); + + const QString baseUrl = capturedUrl.toString(QUrl::RemoveQuery); + QCOMPARE(baseUrl, QStringLiteral("https://www.dropbox.com/oauth2/authorize")); + + const QUrlQuery q(capturedUrl); + QCOMPARE(q.queryItemValue(QStringLiteral("client_id")), QStringLiteral("test-app-key")); + QCOMPARE(q.queryItemValue(QStringLiteral("response_type")), QStringLiteral("code")); + QCOMPARE(q.queryItemValue(QStringLiteral("code_challenge_method")), QStringLiteral("S256")); + // token_access_type=offline is what makes Dropbox issue a refresh_token; a + // regression that drops this param silently breaks long-term auth. + QCOMPARE(q.queryItemValue(QStringLiteral("token_access_type")), QStringLiteral("offline")); + + QVERIFY(q.hasQueryItem(QStringLiteral("code_challenge"))); + QVERIFY(!q.queryItemValue(QStringLiteral("code_challenge")).isEmpty()); + QVERIFY(q.hasQueryItem(QStringLiteral("state"))); + QVERIFY(!q.queryItemValue(QStringLiteral("state")).isEmpty()); + + // Whether redirect_uri is present depends on whether the local port could + // be bound on the test machine — don't assert on it here. + + flow.cancel(); +} + +void TestDropboxLoginFlow::testStartAuthorization_manualFallback_emitsManualFallbackWithVerifier() +{ + QTcpServer blocker; + if (!blocker.listen(QHostAddress::LocalHost, kLocalCallbackPort)) { + QSKIP("Cannot bind localhost:12345 — port unavailable on this machine"); + } + + DropboxLoginFlow flow; + QUrl capturedUrl; + flow.setBrowserOpener([&capturedUrl](const QUrl& url) { capturedUrl = url; }); + + QSignalSpy fallbackSpy(&flow, &DropboxLoginFlow::authorizationManualFallback); + flow.startAuthorization(QStringLiteral("appkey"), 1000); + + QCOMPARE(fallbackSpy.count(), 1); + const QString verifier = fallbackSpy.first().at(0).toString(); + QVERIFY2(verifier.length() >= 43 && verifier.length() <= 128, + qPrintable(QStringLiteral("verifier length out of RFC range: %1").arg(verifier.length()))); + const QRegularExpression base64Url(QStringLiteral("^[A-Za-z0-9_-]+$")); + QVERIFY(base64Url.match(verifier).hasMatch()); + + // Manual fallback branch must NOT include redirect_uri in the authorize URL + // — Dropbox's PKCE token exchange would otherwise demand a matching value. + const QUrlQuery q(capturedUrl); + QVERIFY(!q.hasQueryItem(QStringLiteral("redirect_uri"))); +} + +// --------------------------------------------------------------------------- +// cancel semantics +// --------------------------------------------------------------------------- + +void TestDropboxLoginFlow::testCancel_inIdle_isNoop() +{ + DropboxLoginFlow flow; + QSignalSpy cancelledSpy(&flow, &DropboxLoginFlow::authorizationCancelled); + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + QSignalSpy completedSpy(&flow, &DropboxLoginFlow::authorizationCompleted); + + flow.cancel(); + + QCOMPARE(cancelledSpy.count(), 0); + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(completedSpy.count(), 0); +} + +void TestDropboxLoginFlow::testCancel_inAuthorizing_emitsAuthorizationCancelled() +{ + DropboxLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + // Bypass manual-fallback by ensuring the port is bindable: if it's NOT + // bindable, the flow lands in ManualFallback rather than Authorizing. + // Probe with a temporary listener; if probe succeeds, port is free and the + // real start will succeed too (we close the probe first). + QTcpServer probe; + if (!probe.listen(QHostAddress::LocalHost, kLocalCallbackPort)) { + QSKIP("Cannot bind localhost:12345 — port unavailable, cannot reach Authorizing state"); + } + probe.close(); + + QSignalSpy cancelledSpy(&flow, &DropboxLoginFlow::authorizationCancelled); + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + QSignalSpy completedSpy(&flow, &DropboxLoginFlow::authorizationCompleted); + + // Long timeout so the timer doesn't preempt our cancel. + flow.startAuthorization(QStringLiteral("appkey"), 60000); + flow.cancel(); + + QCOMPARE(cancelledSpy.count(), 1); + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(completedSpy.count(), 0); +} + +void TestDropboxLoginFlow::testCancel_inManualFallback_emitsAuthorizationCancelled() +{ + QTcpServer blocker; + if (!blocker.listen(QHostAddress::LocalHost, kLocalCallbackPort)) { + QSKIP("Cannot bind localhost:12345 — port unavailable on this machine"); + } + + DropboxLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + QSignalSpy fallbackSpy(&flow, &DropboxLoginFlow::authorizationManualFallback); + flow.startAuthorization(QStringLiteral("appkey"), 1000); + QCOMPARE(fallbackSpy.count(), 1); + + QSignalSpy cancelledSpy(&flow, &DropboxLoginFlow::authorizationCancelled); + flow.cancel(); + + QCOMPARE(cancelledSpy.count(), 1); +} + +void TestDropboxLoginFlow::testCancel_afterTerminalCompletedOrFailed_doesNotReEmit() +{ + DropboxLoginFlow flow; + QSignalSpy failedSpy(&flow, &DropboxLoginFlow::authorizationFailed); + // Drive to Failed via empty-appKey. + flow.startAuthorization(QStringLiteral(""), 1000); + QCOMPARE(failedSpy.count(), 1); + + QSignalSpy cancelledSpy(&flow, &DropboxLoginFlow::authorizationCancelled); + flow.cancel(); + + QCOMPARE(cancelledSpy.count(), 0); +} diff --git a/tests/TestDropboxLoginFlow.h b/tests/TestDropboxLoginFlow.h new file mode 100644 index 0000000000..52e5c2ae8e --- /dev/null +++ b/tests/TestDropboxLoginFlow.h @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTDROPBOXLOGINFLOW_H +#define KEEPASSX_TESTDROPBOXLOGINFLOW_H + +#include + +class TestDropboxLoginFlow : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + // PKCE pure statics + void testGenerateCodeVerifier_lengthAndCharset(); + void testGenerateCodeVerifier_isRandom(); + void testDeriveCodeChallenge_S256_isDeterministic(); + void testDeriveCodeChallenge_S256_matchesRFC7636Vector(); + void testDeriveCodeChallenge_differentInputsProduceDifferentOutputs(); + + // State guards on submitManualCode + void testSubmitManualCode_withoutManualFallbackState_emitsFailed(); + void testSubmitManualCode_emptyCode_emitsFailed(); + void testSubmitManualCode_whitespaceCode_emitsFailed(); + + // startAuthorization guards + void testStartAuthorization_emptyAppKey_emitsFailed(); + void testStartAuthorization_browserOpenerCalledWithCorrectQuery(); + void testStartAuthorization_manualFallback_emitsManualFallbackWithVerifier(); + + // cancel semantics + void testCancel_inIdle_isNoop(); + void testCancel_inAuthorizing_emitsAuthorizationCancelled(); + void testCancel_inManualFallback_emitsAuthorizationCancelled(); + void testCancel_afterTerminalCompletedOrFailed_doesNotReEmit(); +}; + +#endif // KEEPASSX_TESTDROPBOXLOGINFLOW_H diff --git a/tests/TestDropboxSyncProvider.cpp b/tests/TestDropboxSyncProvider.cpp new file mode 100644 index 0000000000..1b77e9b43a --- /dev/null +++ b/tests/TestDropboxSyncProvider.cpp @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestDropboxSyncProvider.h" + +#include "mock/MockClock.h" + +#include "core/Clock.h" +#include "core/Database.h" +#include "crypto/Crypto.h" +#include "gui/remote/RemoteHandler.h" +#include "gui/remote/RemoteSettings.h" +#include "remotesync/DropboxSyncProvider.h" +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestDropboxSyncProvider) + +void TestDropboxSyncProvider::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +// --------------------------------------------------------------------------- +// buildParamsFromConfig +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testBuildParamsFromConfig_extractsAllFields() +{ + DropboxSyncProvider provider; + const qint64 expiresMs = QDateTime(QDate(2030, 1, 1), QTime(12, 0, 0), Qt::UTC).toMSecsSinceEpoch(); + + QJsonObject config; + config[QStringLiteral("name")] = QStringLiteral("My Dropbox"); + config[QStringLiteral("appKey")] = QStringLiteral("abc123"); + config[QStringLiteral("remotePath")] = QStringLiteral("/Apps/KeePassXC/db.kdbx"); + config[QStringLiteral("accessToken")] = QStringLiteral("at-token"); + config[QStringLiteral("refreshToken")] = QStringLiteral("rt-token"); + config[QStringLiteral("expiresAt")] = expiresMs; + + QScopedPointer params(provider.buildParamsFromConfig(config)); + QVERIFY(params); + auto* dpx = dynamic_cast(params.data()); + QVERIFY(dpx); + QCOMPARE(dpx->type, QStringLiteral("dropbox")); + QCOMPARE(dpx->name, QStringLiteral("My Dropbox")); + QCOMPARE(dpx->appKey, QStringLiteral("abc123")); + QCOMPARE(dpx->remotePath, QStringLiteral("/Apps/KeePassXC/db.kdbx")); + QCOMPARE(dpx->accessToken, QStringLiteral("at-token")); + QCOMPARE(dpx->refreshToken, QStringLiteral("rt-token")); + QVERIFY(dpx->expiresAt.isValid()); + QCOMPARE(dpx->expiresAt.toMSecsSinceEpoch(), expiresMs); +} + +void TestDropboxSyncProvider::testBuildParamsFromConfig_missingExpiresAt() +{ + DropboxSyncProvider provider; + QJsonObject config; + config[QStringLiteral("name")] = QStringLiteral("My Dropbox"); + config[QStringLiteral("appKey")] = QStringLiteral("abc123"); + config[QStringLiteral("remotePath")] = QStringLiteral("/db.kdbx"); + config[QStringLiteral("accessToken")] = QStringLiteral("at"); + config[QStringLiteral("refreshToken")] = QStringLiteral("rt"); + // expiresAt deliberately omitted + + QScopedPointer params(provider.buildParamsFromConfig(config)); + QVERIFY(params); + auto* dpx = dynamic_cast(params.data()); + QVERIFY(dpx); + // Absent expiresAt must remain invalid (not silently populated from 0 ms). + QVERIFY(!dpx->expiresAt.isValid()); +} + +// --------------------------------------------------------------------------- +// applyRefreshedTokens +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testApplyRefreshedTokens_updatesAccessTokenAndExpiry() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.accessToken = QStringLiteral("old-access"); + params.refreshToken = QStringLiteral("kept-refresh"); + params.expiresAt = QDateTime(QDate(2000, 1, 1), QTime(0, 0, 0), Qt::UTC); + + const qint64 newExpiresMs = QDateTime(QDate(2030, 1, 1), QTime(12, 0, 0), Qt::UTC).toMSecsSinceEpoch(); + QJsonObject tokenData; + tokenData[QStringLiteral("accessToken")] = QStringLiteral("new-access"); + tokenData[QStringLiteral("expiresAt")] = newExpiresMs; + const QString stdOutput = QString::fromUtf8(QJsonDocument(tokenData).toJson(QJsonDocument::Compact)); + + QVERIFY(provider.applyRefreshedTokens(stdOutput, ¶ms)); + QCOMPARE(params.accessToken, QStringLiteral("new-access")); + QVERIFY(params.expiresAt.isValid()); + QCOMPARE(params.expiresAt.toMSecsSinceEpoch(), newExpiresMs); + // CRITICAL: Dropbox does not return refresh_token on refresh; the existing + // refreshToken must survive applyRefreshedTokens unchanged. A regression + // here breaks long-term auth (cannot refresh again after access expires). + QCOMPARE(params.refreshToken, QStringLiteral("kept-refresh")); +} + +void TestDropboxSyncProvider::testApplyRefreshedTokens_emptyStdOutputReturnsTrue() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.accessToken = QStringLiteral("unchanged"); + params.refreshToken = QStringLiteral("rt"); + // Empty stdOutput is the proactive-refresh-skipped path: must succeed + // as a no-op without touching params. + QVERIFY(provider.applyRefreshedTokens(QString(), ¶ms)); + QCOMPARE(params.accessToken, QStringLiteral("unchanged")); + QCOMPARE(params.refreshToken, QStringLiteral("rt")); +} + +void TestDropboxSyncProvider::testApplyRefreshedTokens_malformedJsonReturnsFalse() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.accessToken = QStringLiteral("untouched"); + QTest::ignoreMessage(QtWarningMsg, "DropboxSyncProvider: failed to parse refreshed token JSON"); + QVERIFY(!provider.applyRefreshedTokens(QStringLiteral("not json{"), ¶ms)); + QCOMPARE(params.accessToken, QStringLiteral("untouched")); +} + +// --------------------------------------------------------------------------- +// classifyError +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testClassifyError_invalidAccessToken_AuthExpired() +{ + DropboxSyncProvider provider; + QCOMPARE(provider.classifyError(QStringLiteral("invalid_access_token")), + RemoteSyncProvider::ErrorKind::AuthExpired); + QCOMPARE(provider.classifyError(QStringLiteral("expired_access_token")), + RemoteSyncProvider::ErrorKind::AuthExpired); + // Case-insensitivity guard -- Dropbox error tags may arrive in any case. + QCOMPARE(provider.classifyError(QStringLiteral("INVALID_ACCESS_TOKEN")), + RemoteSyncProvider::ErrorKind::AuthExpired); +} + +void TestDropboxSyncProvider::testClassifyError_invalidGrant_AuthRevoked() +{ + DropboxSyncProvider provider; + QCOMPARE(provider.classifyError(QStringLiteral("invalid_grant")), + RemoteSyncProvider::ErrorKind::AuthRevoked); +} + +void TestDropboxSyncProvider::testClassifyError_unknown_Other() +{ + DropboxSyncProvider provider; + QCOMPARE(provider.classifyError(QStringLiteral("totally random error")), + RemoteSyncProvider::ErrorKind::Other); +} + +// --------------------------------------------------------------------------- +// isAuthorized +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testIsAuthorized_requiresAllFourFields() +{ + DropboxSyncProvider provider; + + auto fullConfig = []() { + QJsonObject c; + c[QStringLiteral("accessToken")] = QStringLiteral("at"); + c[QStringLiteral("refreshToken")] = QStringLiteral("rt"); + c[QStringLiteral("appKey")] = QStringLiteral("ak"); + c[QStringLiteral("remotePath")] = QStringLiteral("/db.kdbx"); + return c; + }; + + // Fully populated config is authorized. + QVERIFY(provider.isAuthorized(fullConfig())); + + // Each individual missing field must flip the verdict to false. Tests + // the AND-of-four contract documented in isAuthorized(). + for (const QString& missing : + {QStringLiteral("accessToken"), + QStringLiteral("refreshToken"), + QStringLiteral("appKey"), + QStringLiteral("remotePath")}) { + QJsonObject c = fullConfig(); + c[missing] = QStringLiteral(""); + QVERIFY2(!provider.isAuthorized(c), + qPrintable(QStringLiteral("expected unauthorized when '%1' missing").arg(missing))); + } +} + +// --------------------------------------------------------------------------- +// Entry-point validation (no network) +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testDownload_rejectsRelativeRemotePath() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.accessToken = QStringLiteral("at"); + params.refreshToken = QStringLiteral("rt"); + params.appKey = QStringLiteral("ak"); + params.remotePath = QStringLiteral("no-leading-slash"); + + // Validation must happen before ensureNam() -- if QNAM is touched here, + // the test would either hit the network or fail later. We assert the + // synchronous validation rejection. + const auto result = provider.download(¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("must start with '/'")), + qPrintable(result.errorMessage)); +} + +void TestDropboxSyncProvider::testUpload_rejectsRelativeRemotePath() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.accessToken = QStringLiteral("at"); + params.remotePath = QStringLiteral("relative/path.kdbx"); + + const auto result = provider.upload(QStringLiteral("/tmp/whatever.kdbx"), ¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("must start with '/'")), + qPrintable(result.errorMessage)); +} + +void TestDropboxSyncProvider::testUpload_rejectsMissingFile() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.accessToken = QStringLiteral("at"); + params.remotePath = QStringLiteral("/foo.kdbx"); + + const QString missingFile = QStringLiteral("/this/path/should/not/exist/db.kdbx"); + const auto result = provider.upload(missingFile, ¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("Failed to open file")), + qPrintable(result.errorMessage)); +} + +// --------------------------------------------------------------------------- +// refreshAuth early-return paths +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testRefreshAuth_emptyRefreshToken_returnsAuthRevoked() +{ + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.refreshToken = QString(); // empty + params.appKey = QStringLiteral("ak"); + + const auto result = provider.refreshAuth(¶ms); + QVERIFY(!result.success); + QCOMPARE(result.kind, RemoteSyncProvider::ErrorKind::AuthRevoked); +} + +void TestDropboxSyncProvider::testRefreshAuth_validTokenWithinBuffer_skipsRefresh() +{ + // Pin the clock so the proactive-skip window is deterministic. The + // skip path returns success without touching QNAM; this locks in that + // optimization (regression: an extra HTTP call per sync would burn + // Dropbox rate-limit headroom). + auto* clock = new MockClock(2025, 1, 1, 12, 0, 0); + MockClock::setup(clock); + + DropboxSyncProvider provider; + DropboxSyncParams params; + params.type = QStringLiteral("dropbox"); + params.refreshToken = QStringLiteral("rt"); + params.appKey = QStringLiteral("ak"); + // 1 hour ahead -- well outside the 10-minute proactive buffer. + params.expiresAt = Clock::currentDateTimeUtc().addSecs(3600); + + const auto result = provider.refreshAuth(¶ms); + QVERIFY(result.success); + QVERIFY(result.stdOutput.isEmpty()); // empty stdOutput is the skip signal + + MockClock::teardown(); +} + +// --------------------------------------------------------------------------- +// persistRefreshedTokens +// --------------------------------------------------------------------------- + +void TestDropboxSyncProvider::testPersistRefreshedTokens_updatesAccessTokenOnly() +{ + DropboxSyncProvider provider; + RemoteSettings settings(QSharedPointer(), nullptr); + + const QString configKey = QStringLiteral("myKey"); + const qint64 oldExpires = QDateTime(QDate(2020, 1, 1), QTime(0, 0, 0), Qt::UTC).toMSecsSinceEpoch(); + const qint64 newExpires = QDateTime(QDate(2030, 6, 6), QTime(6, 6, 6), Qt::UTC).toMSecsSinceEpoch(); + + QJsonObject existing; + existing[QStringLiteral("type")] = QStringLiteral("dropbox"); + existing[QStringLiteral("name")] = configKey; + existing[QStringLiteral("appKey")] = QStringLiteral("ak-original"); + existing[QStringLiteral("remotePath")] = QStringLiteral("/db.kdbx"); + existing[QStringLiteral("accessToken")] = QStringLiteral("old-at"); + existing[QStringLiteral("refreshToken")] = QStringLiteral("rt-must-survive"); + existing[QStringLiteral("expiresAt")] = oldExpires; + settings.setProviderConfig(QStringLiteral("dropbox"), configKey, existing); + + QJsonObject tokenData; + tokenData[QStringLiteral("accessToken")] = QStringLiteral("new-at"); + tokenData[QStringLiteral("expiresAt")] = newExpires; + const QString stdOutput = QString::fromUtf8(QJsonDocument(tokenData).toJson(QJsonDocument::Compact)); + + provider.persistRefreshedTokens(stdOutput, configKey, &settings); + + const QJsonObject updated = settings.getProviderConfig(QStringLiteral("dropbox"), configKey); + QCOMPARE(updated.value(QStringLiteral("accessToken")).toString(), QStringLiteral("new-at")); + QCOMPARE(updated.value(QStringLiteral("expiresAt")).toVariant().toLongLong(), newExpires); + // CRITICAL: Dropbox refresh response carries no refresh_token. The + // persisted refreshToken must remain the original -- a regression that + // overwrote it with empty would silently break long-term auth. + QCOMPARE(updated.value(QStringLiteral("refreshToken")).toString(), QStringLiteral("rt-must-survive")); + QCOMPARE(updated.value(QStringLiteral("appKey")).toString(), QStringLiteral("ak-original")); + QCOMPARE(updated.value(QStringLiteral("remotePath")).toString(), QStringLiteral("/db.kdbx")); + QCOMPARE(updated.value(QStringLiteral("name")).toString(), configKey); +} + +void TestDropboxSyncProvider::testPersistRefreshedTokens_unknownConfigKey_noopWithWarning() +{ + DropboxSyncProvider provider; + RemoteSettings settings(QSharedPointer(), nullptr); + + QJsonObject tokenData; + tokenData[QStringLiteral("accessToken")] = QStringLiteral("new-at"); + const QString stdOutput = QString::fromUtf8(QJsonDocument(tokenData).toJson(QJsonDocument::Compact)); + + QTest::ignoreMessage( + QtWarningMsg, + "DropboxSyncProvider: no Dropbox config found for 'does-not-exist' to update tokens"); + provider.persistRefreshedTokens(stdOutput, QStringLiteral("does-not-exist"), &settings); + + // Settings unchanged: getProviderConfig for the unknown key still empty. + QVERIFY(settings.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("does-not-exist")).isEmpty()); +} + +void TestDropboxSyncProvider::testPersistRefreshedTokens_malformedJson_noopWithWarning() +{ + DropboxSyncProvider provider; + RemoteSettings settings(QSharedPointer(), nullptr); + + const QString configKey = QStringLiteral("k"); + QJsonObject existing; + existing[QStringLiteral("type")] = QStringLiteral("dropbox"); + existing[QStringLiteral("name")] = configKey; + existing[QStringLiteral("accessToken")] = QStringLiteral("stay"); + existing[QStringLiteral("refreshToken")] = QStringLiteral("rt"); + settings.setProviderConfig(QStringLiteral("dropbox"), configKey, existing); + + QTest::ignoreMessage(QtWarningMsg, "DropboxSyncProvider: failed to parse refreshed token JSON for persist"); + provider.persistRefreshedTokens(QStringLiteral("{ broken"), configKey, &settings); + + const QJsonObject updated = settings.getProviderConfig(QStringLiteral("dropbox"), configKey); + QCOMPARE(updated.value(QStringLiteral("accessToken")).toString(), QStringLiteral("stay")); + QCOMPARE(updated.value(QStringLiteral("refreshToken")).toString(), QStringLiteral("rt")); +} diff --git a/tests/TestDropboxSyncProvider.h b/tests/TestDropboxSyncProvider.h new file mode 100644 index 0000000000..93f6bf99c6 --- /dev/null +++ b/tests/TestDropboxSyncProvider.h @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTDROPBOXSYNCPROVIDER_H +#define KEEPASSXC_TESTDROPBOXSYNCPROVIDER_H + +#include + +class TestDropboxSyncProvider : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + // buildParamsFromConfig + void testBuildParamsFromConfig_extractsAllFields(); + void testBuildParamsFromConfig_missingExpiresAt(); + + // applyRefreshedTokens + void testApplyRefreshedTokens_updatesAccessTokenAndExpiry(); + void testApplyRefreshedTokens_emptyStdOutputReturnsTrue(); + void testApplyRefreshedTokens_malformedJsonReturnsFalse(); + + // classifyError + void testClassifyError_invalidAccessToken_AuthExpired(); + void testClassifyError_invalidGrant_AuthRevoked(); + void testClassifyError_unknown_Other(); + + // isAuthorized + void testIsAuthorized_requiresAllFourFields(); + + // Entry-point validation (no network) + void testDownload_rejectsRelativeRemotePath(); + void testUpload_rejectsRelativeRemotePath(); + void testUpload_rejectsMissingFile(); + + // refreshAuth early-return paths + void testRefreshAuth_emptyRefreshToken_returnsAuthRevoked(); + void testRefreshAuth_validTokenWithinBuffer_skipsRefresh(); + + // persistRefreshedTokens + void testPersistRefreshedTokens_updatesAccessTokenOnly(); + void testPersistRefreshedTokens_unknownConfigKey_noopWithWarning(); + void testPersistRefreshedTokens_malformedJson_noopWithWarning(); +}; + +#endif // KEEPASSXC_TESTDROPBOXSYNCPROVIDER_H diff --git a/tests/TestHttpRetryHelper.cpp b/tests/TestHttpRetryHelper.cpp new file mode 100644 index 0000000000..bcdcc38428 --- /dev/null +++ b/tests/TestHttpRetryHelper.cpp @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestHttpRetryHelper.h" + +#include "crypto/Crypto.h" +#include "remotesync/HttpRetryHelper.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestHttpRetryHelper) + +namespace +{ + +/** + * Tiny canned HTTP/1.1 server. Each accepted connection is answered with the + * next pre-queued raw response, then closed. If the queue runs dry, the + * connection is closed without writing -- which surfaces as an error reply. + */ +class CannedHttpServer : public QObject +{ + Q_OBJECT +public: + explicit CannedHttpServer(QObject* parent = nullptr) + : QObject(parent) + , m_server(new QTcpServer(this)) + { + connect(m_server, &QTcpServer::newConnection, this, &CannedHttpServer::onNewConnection); + } + + bool start() + { + return m_server->listen(QHostAddress::LocalHost, 0); + } + + void stop() + { + m_server->close(); + } + + quint16 port() const + { + return m_server->serverPort(); + } + + /** + * Queue a canned response. status is the HTTP status code; headers are + * extra header lines (each ending with \r\n, no Content-Length needed -- + * it's appended automatically); body is the response body. + */ + void queueResponse(int status, const QByteArray& extraHeaders = {}, const QByteArray& body = {}) + { + QByteArray reasonPhrase; + switch (status) { + case 200: + reasonPhrase = "OK"; + break; + case 429: + reasonPhrase = "Too Many Requests"; + break; + case 500: + reasonPhrase = "Internal Server Error"; + break; + default: + reasonPhrase = "Status"; + break; + } + + QByteArray response; + response += "HTTP/1.1 " + QByteArray::number(status) + " " + reasonPhrase + "\r\n"; + response += "Content-Length: " + QByteArray::number(body.size()) + "\r\n"; + response += "Connection: close\r\n"; + if (!extraHeaders.isEmpty()) { + response += extraHeaders; + } + response += "\r\n"; + response += body; + m_responses.append(response); + } + + int connectionCount() const + { + return m_connectionCount; + } + +private slots: + void onNewConnection() + { + while (m_server->hasPendingConnections()) { + auto* socket = m_server->nextPendingConnection(); + ++m_connectionCount; + + // Read the request until we see the end of headers, then respond. + connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { + m_buffers[socket].append(socket->readAll()); + if (!m_buffers[socket].contains("\r\n\r\n")) { + return; + } + if (!m_responses.isEmpty()) { + QByteArray response = m_responses.takeFirst(); + socket->write(response); + socket->flush(); + } + socket->disconnectFromHost(); + }); + connect(socket, &QTcpSocket::disconnected, this, [this, socket]() { + m_buffers.remove(socket); + socket->deleteLater(); + }); + } + } + +private: + QTcpServer* m_server; + QList m_responses; + QHash m_buffers; + int m_connectionCount = 0; +}; + +} // namespace + +#include "TestHttpRetryHelper.moc" + +void TestHttpRetryHelper::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +void TestHttpRetryHelper::testIsRetryable_table_data() +{ + QTest::addColumn("status"); + QTest::addColumn("retryable"); + + // Success / redirect: never retry. + QTest::newRow("200 OK") << 200 << false; + QTest::newRow("201 Created") << 201 << false; + QTest::newRow("204 No Content") << 204 << false; + QTest::newRow("301 Moved") << 301 << false; + QTest::newRow("304 Not Modified") << 304 << false; + + // 4xx: never retry EXCEPT 429. + QTest::newRow("400 Bad Request") << 400 << false; + QTest::newRow("401 Unauthorized") << 401 << false; + QTest::newRow("403 Forbidden") << 403 << false; + QTest::newRow("404 Not Found") << 404 << false; + QTest::newRow("428 (just below 429)") << 428 << false; + QTest::newRow("429 Too Many Requests") << 429 << true; + QTest::newRow("430 (just above 429)") << 430 << false; + QTest::newRow("499") << 499 << false; + + // 5xx: always retry. + QTest::newRow("500 Internal Server Error") << 500 << true; + QTest::newRow("502 Bad Gateway") << 502 << true; + QTest::newRow("503 Service Unavailable") << 503 << true; + QTest::newRow("504 Gateway Timeout") << 504 << true; + QTest::newRow("599 (upper edge)") << 599 << true; + + // Outside HTTP range: never retry. + QTest::newRow("0 (no status)") << 0 << false; + QTest::newRow("600 (above 5xx)") << 600 << false; +} + +void TestHttpRetryHelper::testIsRetryable_table() +{ + QFETCH(int, status); + QFETCH(bool, retryable); + QCOMPARE(HttpRetryHelper::isRetryable(status), retryable); +} + +void TestHttpRetryHelper::testExecute_succeedsFirstAttempt() +{ + CannedHttpServer server; + QVERIFY(server.start()); + server.queueResponse(200); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + RetryPolicy policy; + policy.maxRetries = 3; + policy.baseDelayMs = 1; + + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, nullptr); + QVERIFY(reply != nullptr); + QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200); + QCOMPARE(callCount, 1); + reply->deleteLater(); +} + +void TestHttpRetryHelper::testExecute_retriesOn500ThenSucceeds() +{ + CannedHttpServer server; + QVERIFY(server.start()); + server.queueResponse(500); + server.queueResponse(200); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + RetryPolicy policy; + policy.maxRetries = 3; + policy.baseDelayMs = 1; // keep wall-clock low; jitter*1ms is still ~ms + + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, nullptr); + QVERIFY(reply != nullptr); + QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200); + QCOMPARE(callCount, 2); + reply->deleteLater(); +} + +void TestHttpRetryHelper::testExecute_exhaustsRetriesAndReturnsLastFailure() +{ + CannedHttpServer server; + QVERIFY(server.start()); + // maxRetries=2 means up to 3 total attempts (initial + 2 retries). + server.queueResponse(500); + server.queueResponse(500); + server.queueResponse(500); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + RetryPolicy policy; + policy.maxRetries = 2; + policy.baseDelayMs = 1; + + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, nullptr); + QVERIFY(reply != nullptr); // must NOT be nullptr -- caller needs to see the last failure + QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 500); + QCOMPARE(callCount, 3); // 1 initial + 2 retries + reply->deleteLater(); +} + +void TestHttpRetryHelper::testExecute_retryAfterCapTriggersImmediateFailure() +{ + CannedHttpServer server; + QVERIFY(server.start()); + // 429 with Retry-After: 9999s -- well beyond the 60s default cap. + server.queueResponse(429, "Retry-After: 9999\r\n"); + // Sentinel: if the helper incorrectly retries, it'd consume this 200. + server.queueResponse(200); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + RetryPolicy policy; + policy.maxRetries = 3; + policy.baseDelayMs = 1; + policy.maxRetryAfterSec = 60; + + QElapsedTimer wallClock; + wallClock.start(); + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, nullptr); + qint64 elapsed = wallClock.elapsed(); + + QVERIFY(reply != nullptr); + QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 429); + QCOMPARE(callCount, 1); + // Should have returned immediately, NOT slept 9999s. + QVERIFY2(elapsed < 5000, qPrintable(QString("elapsed=%1ms").arg(elapsed))); + reply->deleteLater(); +} + +void TestHttpRetryHelper::testExecute_abortFlagShortCircuits() +{ + CannedHttpServer server; + QVERIFY(server.start()); + // Queue a 200 sentinel; if helper ignores abort, it'd consume this. + server.queueResponse(200); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + QAtomicInt abortFlag(1); // set before call + + RetryPolicy policy; + policy.maxRetries = 3; + policy.baseDelayMs = 1; + + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, &abortFlag); + QCOMPARE(callCount, 0); + QVERIFY(reply == nullptr); + QCOMPARE(server.connectionCount(), 0); +} + +void TestHttpRetryHelper::testExecute_abortFlagDuringDelayBreaksOut() +{ + CannedHttpServer server; + QVERIFY(server.start()); + server.queueResponse(500); + // Sentinel 200: if helper proceeds with retry, callCount would be 2. + server.queueResponse(200); + + QNetworkAccessManager nam; + int callCount = 0; + auto makeRequest = [&]() -> QNetworkReply* { + ++callCount; + QNetworkRequest req(QUrl(QString("http://127.0.0.1:%1/").arg(server.port()))); + return nam.get(req); + }; + + QAtomicInt abortFlag(0); + // Flip the flag ~50ms into the call -- which is inside the ~500ms delay. + QTimer::singleShot(50, [&]() { abortFlag.storeRelease(1); }); + + RetryPolicy policy; + policy.maxRetries = 3; + policy.baseDelayMs = 500; // delay window long enough for the 50ms flip to land inside + + QElapsedTimer wallClock; + wallClock.start(); + QNetworkReply* reply = HttpRetryHelper::execute(makeRequest, policy, 5000, &abortFlag); + qint64 elapsed = wallClock.elapsed(); + + QVERIFY(reply != nullptr); + QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 500); + QCOMPARE(callCount, 1); + // Bounded wall-clock: should have aborted well before a full retry cycle. + QVERIFY2(elapsed < 1000, qPrintable(QString("elapsed=%1ms").arg(elapsed))); + reply->deleteLater(); +} diff --git a/tests/TestHttpRetryHelper.h b/tests/TestHttpRetryHelper.h new file mode 100644 index 0000000000..accd816e8e --- /dev/null +++ b/tests/TestHttpRetryHelper.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTHTTPRETRYHELPER_H +#define KEEPASSX_TESTHTTPRETRYHELPER_H + +#include + +class TestHttpRetryHelper : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void testIsRetryable_table_data(); + void testIsRetryable_table(); + + void testExecute_succeedsFirstAttempt(); + void testExecute_retriesOn500ThenSucceeds(); + void testExecute_exhaustsRetriesAndReturnsLastFailure(); + void testExecute_retryAfterCapTriggersImmediateFailure(); + void testExecute_abortFlagShortCircuits(); + void testExecute_abortFlagDuringDelayBreaksOut(); +}; + +#endif // KEEPASSX_TESTHTTPRETRYHELPER_H diff --git a/tests/TestNextcloudLoginFlow.cpp b/tests/TestNextcloudLoginFlow.cpp new file mode 100644 index 0000000000..8f8e094ae9 --- /dev/null +++ b/tests/TestNextcloudLoginFlow.cpp @@ -0,0 +1,831 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestNextcloudLoginFlow.h" +#include "remotesync/NextcloudLoginFlow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestNextcloudLoginFlow) + +namespace +{ + // ----------------------------------------------------------------------- + // FakeNextcloudServer: a tiny in-process HTTP server that speaks just + // enough HTTP/1.1 to satisfy QNetworkAccessManager. Per-path canned + // responses are queued; each incoming request pops the next response for + // the matching path. Each request URL path is also recorded for later + // assertion. + // ----------------------------------------------------------------------- + struct CannedResponse + { + int statusCode = 200; + QByteArray body; + // If true, instead of replying, close the socket immediately after + // receiving the request (for "network error" tests). NOTE: this + // currently produces a closed-without-response which the client + // surfaces as a QNetworkReply error. + bool dropConnection = false; + // If non-zero, delay the reply by this many ms before sending bytes. + // Used by cancel-during-* tests to keep the reply pending. + int delayMs = 0; + }; + + class FakeNextcloudServer : public QObject + { + Q_OBJECT + public: + explicit FakeNextcloudServer(QObject* parent = nullptr) + : QObject(parent) + { + connect(&m_server, &QTcpServer::newConnection, this, &FakeNextcloudServer::onNewConnection); + } + + bool listen() + { + return m_server.listen(QHostAddress::LocalHost, 0); + } + + quint16 port() const + { + return m_server.serverPort(); + } + + QString baseUrl() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(port()); + } + + // Queue a canned response. path is the URL path (e.g. "/index.php/login/v2"). + void queueResponse(const QString& path, const CannedResponse& r) + { + m_responses[path].append(r); + } + + // Default response for any path that has no queued response: 404. + // Useful for "keep polling on 404" — queue only 1 200 and let the + // server backfill 404s. + void setDefaultResponse(const QString& path, const CannedResponse& r) + { + m_defaults[path] = r; + } + + QStringList requestedPaths() const + { + return m_requestedPaths; + } + + int requestCountFor(const QString& path) const + { + int n = 0; + for (const QString& p : m_requestedPaths) { + if (p == path) { + ++n; + } + } + return n; + } + + private slots: + void onNewConnection() + { + while (m_server.hasPendingConnections()) { + QTcpSocket* sock = m_server.nextPendingConnection(); + connect(sock, &QTcpSocket::readyRead, this, [this, sock]() { onReadyRead(sock); }); + connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater); + } + } + + void onReadyRead(QTcpSocket* sock) + { + // Accumulate per-socket buffer until we have the full request + // headers + (for POST) the declared body. Nextcloud Login Flow v2 + // initiate is Content-Length: 0; the poll POST sends a small + // form-urlencoded body. We use Content-Length to detect end of + // request rather than chase chunked encoding (QNAM doesn't send + // chunked for these small bodies in practice). + m_buffers[sock].append(sock->readAll()); + + QByteArray& buf = m_buffers[sock]; + int headerEnd = buf.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; // wait for more bytes + } + + QByteArray headerPart = buf.left(headerEnd); + // Find Content-Length if present. + int contentLength = 0; + for (const QByteArray& line : headerPart.split('\n')) { + if (line.toLower().startsWith("content-length:")) { + contentLength = line.mid(QByteArray("content-length:").size()).trimmed().toInt(); + break; + } + } + if (buf.size() < headerEnd + 4 + contentLength) { + return; // wait for body + } + + // Extract request line: "METHOD PATH HTTP/1.1" + QByteArray requestLine = headerPart.split('\n').value(0).trimmed(); + QList parts = requestLine.split(' '); + QString path; + if (parts.size() >= 2) { + path = QString::fromLatin1(parts.at(1)); + } + m_requestedPaths.append(path); + + // Pick the response: queued first, then default, then 404. + CannedResponse resp; + bool haveResp = false; + if (m_responses.contains(path) && !m_responses[path].isEmpty()) { + resp = m_responses[path].takeFirst(); + haveResp = true; + } else if (m_defaults.contains(path)) { + resp = m_defaults.value(path); + haveResp = true; + } + if (!haveResp) { + resp.statusCode = 404; + resp.body = QByteArray(); + } + + // Clear consumed buffer so a pipelined second request on the + // same socket would parse from a clean slate. In practice QNAM + // closes after one POST so this is defensive. + m_buffers[sock] = buf.mid(headerEnd + 4 + contentLength); + + auto sendReply = [sock, resp]() { + if (sock->state() != QAbstractSocket::ConnectedState) { + return; + } + if (resp.dropConnection) { + sock->abort(); + return; + } + QByteArray reply; + reply.append("HTTP/1.1 ").append(QByteArray::number(resp.statusCode)).append(" X\r\n"); + reply.append("Content-Type: application/json\r\n"); + reply.append("Content-Length: ").append(QByteArray::number(resp.body.size())).append("\r\n"); + reply.append("Connection: close\r\n\r\n"); + reply.append(resp.body); + sock->write(reply); + sock->disconnectFromHost(); + }; + + if (resp.delayMs > 0) { + QTimer::singleShot(resp.delayMs, sock, sendReply); + } else { + sendReply(); + } + } + + private: + QTcpServer m_server; + QMap> m_responses; + QMap m_defaults; + QStringList m_requestedPaths; + QMap m_buffers; + }; + + // Locate a free localhost port that has NO listener — used for + // "network error" testing. Bind a server, capture its port, close it, + // and return the now-vacated port. There is a (very small) race window + // where another process could grab it; CI is single-tenant enough that + // this is acceptable. + quint16 findUnusedLocalPort() + { + QTcpServer tmp; + tmp.listen(QHostAddress::LocalHost, 0); + quint16 p = tmp.serverPort(); + tmp.close(); + return p; + } + + // Make a canned 200 JSON response. + CannedResponse jsonOk(const QByteArray& body) + { + CannedResponse r; + r.statusCode = 200; + r.body = body; + return r; + } + + CannedResponse statusOnly(int status) + { + CannedResponse r; + r.statusCode = status; + r.body = QByteArray(); + return r; + } +} // namespace + +#include "TestNextcloudLoginFlow.moc" + +// ---------------------------------------------------------------------------- +// Happy path +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testHappyPath_initiateThenPollSucceeds() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + // Initiate: 200 with login/poll structure. + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + // Poll: 200 with credential triple. + QByteArray pollBody = + QStringLiteral("{\"server\":\"%1\",\"loginName\":\"alice\",\"appPassword\":\"app-pwd-xyz\"}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/poll"), jsonOk(pollBody)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + + flow.startLoginFlow(base); + + QVERIFY(completedSpy.wait(5000)); + QCOMPARE(initSpy.count(), 1); + QCOMPARE(initSpy.takeFirst().at(0).toUrl(), QUrl(base + QStringLiteral("/login"))); + + QCOMPARE(openedUrls.size(), 1); + QCOMPARE(openedUrls.at(0), QUrl(base + QStringLiteral("/login"))); + + QCOMPARE(completedSpy.count(), 1); + QList args = completedSpy.takeFirst(); + QCOMPARE(args.at(0).toString(), QStringLiteral("alice")); + QCOMPARE(args.at(1).toString(), QStringLiteral("app-pwd-xyz")); + + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(cancelledSpy.count(), 0); +} + +// ---------------------------------------------------------------------------- +// Phishing mitigation +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testPhishing_loginUrlHostMismatch_failsBeforePolling() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"https://evil.com/login\",\"poll\":{\"token\":\"x\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + const QString reason = failedSpy.takeFirst().at(0).toString(); + QVERIFY2(reason.contains(QStringLiteral("unexpected")) || reason.contains(QStringLiteral("Verify your server URL")), + qPrintable(reason)); + + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); + + // Make sure no poll request was ever sent. The flow could only have + // touched the initiate path. + QTest::qWait(50); + QCOMPARE(server.requestCountFor(QStringLiteral("/poll")), 0); +} + +void TestNextcloudLoginFlow::testPhishing_pollEndpointHostMismatch_fails() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + // loginUrl matches host, but pollEndpoint points to evil.com. + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"x\",\"endpoint\":\"https://evil.com/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + NextcloudLoginFlow flow; + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); +} + +void TestNextcloudLoginFlow::testPhishing_pollEndpointSchemeMismatch_fails() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); // http://127.0.0.1: + + // pollEndpoint scheme is https while configured base is http. + QString httpsPoll = QStringLiteral("https://127.0.0.1:%1/poll").arg(server.port()); + QByteArray initiateBody = QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"x\",\"endpoint\":\"%2\"}}") + .arg(base, httpsPoll) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + NextcloudLoginFlow flow; + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); +} + +// ---------------------------------------------------------------------------- +// Initiate failure paths +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testInitiate_networkError_emitsFailed() +{ + quint16 deadPort = findUnusedLocalPort(); + const QString base = QStringLiteral("http://127.0.0.1:%1").arg(deadPort); + + NextcloudLoginFlow flow; + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); +} + +void TestNextcloudLoginFlow::testInitiate_malformedJson_emitsFailed() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + CannedResponse r; + r.statusCode = 200; + r.body = QByteArray("not json"); + server.queueResponse(QStringLiteral("/index.php/login/v2"), r); + + NextcloudLoginFlow flow; + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); +} + +void TestNextcloudLoginFlow::testInitiate_missingFields_emitsFailed() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(QByteArray("{}"))); + + NextcloudLoginFlow flow; + QList openedUrls; + flow.setBrowserOpener([&](const QUrl& u) { openedUrls.append(u); }); + + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(openedUrls.size(), 0); +} + +// ---------------------------------------------------------------------------- +// Polling state machine +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testPolling_keepsPollingOn404() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + // First poll: 404. Second poll: 200 with creds. + server.queueResponse(QStringLiteral("/poll"), statusOnly(404)); + QByteArray pollBody = + QStringLiteral("{\"server\":\"%1\",\"loginName\":\"bob\",\"appPassword\":\"pw\"}").arg(base).toUtf8(); + server.queueResponse(QStringLiteral("/poll"), jsonOk(pollBody)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(completedSpy.wait(5000)); + QCOMPARE(completedSpy.count(), 1); + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(completedSpy.takeFirst().at(0).toString(), QStringLiteral("bob")); + QVERIFY(server.requestCountFor(QStringLiteral("/poll")) >= 2); +} + +void TestNextcloudLoginFlow::testPolling_keepsPollingOn3xx() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + + server.queueResponse(QStringLiteral("/poll"), statusOnly(303)); + QByteArray pollBody = + QStringLiteral("{\"server\":\"%1\",\"loginName\":\"carol\",\"appPassword\":\"pw\"}").arg(base).toUtf8(); + server.queueResponse(QStringLiteral("/poll"), jsonOk(pollBody)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(completedSpy.wait(5000)); + QCOMPARE(completedSpy.count(), 1); + QCOMPARE(failedSpy.count(), 0); +} + +void TestNextcloudLoginFlow::testPolling_hardFailureOn401() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + server.queueResponse(QStringLiteral("/poll"), statusOnly(401)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(completedSpy.count(), 0); + const QString reason = failedSpy.takeFirst().at(0).toString(); + QVERIFY2(reason.contains(QStringLiteral("Lost connection")), qPrintable(reason)); +} + +void TestNextcloudLoginFlow::testPolling_hardFailureOn500() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + server.queueResponse(QStringLiteral("/poll"), statusOnly(500)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(completedSpy.count(), 0); +} + +void TestNextcloudLoginFlow::testPolling_timeoutFiresAfterPollTimeoutMs() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + // Every poll returns 404 indefinitely. + server.setDefaultResponse(QStringLiteral("/poll"), statusOnly(404)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(200); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + QVERIFY(failedSpy.wait(2000)); + QCOMPARE(failedSpy.count(), 1); + QCOMPARE(completedSpy.count(), 0); + const QString reason = failedSpy.takeFirst().at(0).toString(); + QVERIFY2(reason.contains(QStringLiteral("timed out")), qPrintable(reason)); +} + +// ---------------------------------------------------------------------------- +// Cancel semantics +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testCancel_inIdle_isNoop() +{ + NextcloudLoginFlow flow; + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + + flow.cancel(); + QTest::qWait(50); + + QCOMPARE(cancelledSpy.count(), 0); + QCOMPARE(completedSpy.count(), 0); + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(initSpy.count(), 0); +} + +void TestNextcloudLoginFlow::testCancel_duringInitiate_emitsCancelled() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + // Initiate response is delayed long enough that cancel() arrives first. + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + CannedResponse slow = jsonOk(initiateBody); + slow.delayMs = 1000; + server.queueResponse(QStringLiteral("/index.php/login/v2"), slow); + + NextcloudLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + + flow.startLoginFlow(base); + flow.cancel(); + + QVERIFY(cancelledSpy.wait(2000) || cancelledSpy.count() == 1); + QCOMPARE(cancelledSpy.count(), 1); + + // Give the delayed server reply a chance to arrive at the (already-aborted) + // reply object — onInitiateFinished must NOT emit a second terminal signal. + QTest::qWait(1500); + QCOMPARE(initSpy.count(), 0); + QCOMPARE(completedSpy.count(), 0); + QCOMPARE(failedSpy.count(), 0); + QCOMPARE(cancelledSpy.count(), 1); +} + +void TestNextcloudLoginFlow::testCancel_duringPolling_emitsCancelled() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + // Default poll: 404 forever — keeps the flow in Polling state. + server.setDefaultResponse(QStringLiteral("/poll"), statusOnly(404)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy initSpy(&flow, &NextcloudLoginFlow::loginInitiated); + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + + flow.startLoginFlow(base); + + // Wait until polling is underway. + QVERIFY(initSpy.wait(5000)); + + flow.cancel(); + + QVERIFY(cancelledSpy.wait(2000) || cancelledSpy.count() == 1); + QCOMPARE(cancelledSpy.count(), 1); + + // No double-emit afterward. + QTest::qWait(200); + QCOMPARE(cancelledSpy.count(), 1); + QCOMPARE(completedSpy.count(), 0); + QCOMPARE(failedSpy.count(), 0); +} + +void TestNextcloudLoginFlow::testCancel_afterCompleted_doesNotReEmit() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + server.queueResponse(QStringLiteral("/index.php/login/v2"), jsonOk(initiateBody)); + QByteArray pollBody = + QStringLiteral("{\"server\":\"%1\",\"loginName\":\"u\",\"appPassword\":\"p\"}").arg(base).toUtf8(); + server.queueResponse(QStringLiteral("/poll"), jsonOk(pollBody)); + + NextcloudLoginFlow flow; + flow.setPollIntervalMsForTest(10); + flow.setTimeoutMsForTest(5000); + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy completedSpy(&flow, &NextcloudLoginFlow::loginCompleted); + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + + flow.startLoginFlow(base); + QVERIFY(completedSpy.wait(5000)); + QCOMPARE(completedSpy.count(), 1); + + flow.cancel(); + QTest::qWait(50); + QCOMPARE(cancelledSpy.count(), 0); +} + +void TestNextcloudLoginFlow::testCancel_afterFailed_doesNotReEmit() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + CannedResponse bad; + bad.statusCode = 200; + bad.body = QByteArray("not json"); + server.queueResponse(QStringLiteral("/index.php/login/v2"), bad); + + NextcloudLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy failedSpy(&flow, &NextcloudLoginFlow::loginFailed); + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + + flow.startLoginFlow(base); + QVERIFY(failedSpy.wait(5000)); + QCOMPARE(failedSpy.count(), 1); + + flow.cancel(); + QTest::qWait(50); + QCOMPARE(cancelledSpy.count(), 0); +} + +// ---------------------------------------------------------------------------- +// Cancel-previous on startLoginFlow +// ---------------------------------------------------------------------------- + +void TestNextcloudLoginFlow::testStartLoginFlow_cancelsPreviousFlow() +{ + FakeNextcloudServer server; + QVERIFY(server.listen()); + const QString base = server.baseUrl(); + + // First flow: slow initiate so it is still in-flight when we kick the second. + QByteArray initiateBody = + QStringLiteral("{\"login\":\"%1/login\",\"poll\":{\"token\":\"abc\",\"endpoint\":\"%1/poll\"}}") + .arg(base) + .toUtf8(); + CannedResponse slow = jsonOk(initiateBody); + slow.delayMs = 1000; + server.queueResponse(QStringLiteral("/index.php/login/v2"), slow); + // Second flow's initiate: also slow so we can observe the cancellation + // signal from the first flow without the second flow racing to a terminal. + CannedResponse slow2 = jsonOk(initiateBody); + slow2.delayMs = 2000; + server.queueResponse(QStringLiteral("/index.php/login/v2"), slow2); + + NextcloudLoginFlow flow; + flow.setBrowserOpener([](const QUrl&) {}); + + QSignalSpy cancelledSpy(&flow, &NextcloudLoginFlow::loginCancelled); + + flow.startLoginFlow(base); + // Allow the post to actually leave QNAM before re-entering startLoginFlow. + QTest::qWait(50); + flow.startLoginFlow(base); + + QVERIFY(cancelledSpy.wait(2000) || cancelledSpy.count() == 1); + QCOMPARE(cancelledSpy.count(), 1); + + // Tear down the second flow cleanly so test exit doesn't churn the event loop. + flow.cancel(); +} diff --git a/tests/TestNextcloudLoginFlow.h b/tests/TestNextcloudLoginFlow.h new file mode 100644 index 0000000000..1ca55ab07c --- /dev/null +++ b/tests/TestNextcloudLoginFlow.h @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTNEXTCLOUDLOGINFLOW_H +#define KEEPASSX_TESTNEXTCLOUDLOGINFLOW_H + +#include + +class TestNextcloudLoginFlow : public QObject +{ + Q_OBJECT + +private slots: + // Initiate success -> polling -> completion + void testHappyPath_initiateThenPollSucceeds(); + + // Phishing mitigation + void testPhishing_loginUrlHostMismatch_failsBeforePolling(); + void testPhishing_pollEndpointHostMismatch_fails(); + void testPhishing_pollEndpointSchemeMismatch_fails(); + + // Initiate failure paths + void testInitiate_networkError_emitsFailed(); + void testInitiate_malformedJson_emitsFailed(); + void testInitiate_missingFields_emitsFailed(); + + // Polling state machine + void testPolling_keepsPollingOn404(); + void testPolling_keepsPollingOn3xx(); + void testPolling_hardFailureOn401(); + void testPolling_hardFailureOn500(); + void testPolling_timeoutFiresAfterPollTimeoutMs(); + + // Cancel semantics + void testCancel_inIdle_isNoop(); + void testCancel_duringInitiate_emitsCancelled(); + void testCancel_duringPolling_emitsCancelled(); + void testCancel_afterCompleted_doesNotReEmit(); + void testCancel_afterFailed_doesNotReEmit(); + + // Cancel-previous on startLoginFlow + void testStartLoginFlow_cancelsPreviousFlow(); +}; + +#endif // KEEPASSX_TESTNEXTCLOUDLOGINFLOW_H diff --git a/tests/TestNextcloudSyncProvider.cpp b/tests/TestNextcloudSyncProvider.cpp new file mode 100644 index 0000000000..a00f5be44c --- /dev/null +++ b/tests/TestNextcloudSyncProvider.cpp @@ -0,0 +1,609 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestNextcloudSyncProvider.h" + +#include "crypto/Crypto.h" +#include "remotesync/NextcloudSyncProvider.h" +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using ServerUrlValidity = NextcloudSyncProvider::ServerUrlValidity; +using ErrorKind = RemoteSyncProvider::ErrorKind; + +QTEST_GUILESS_MAIN(TestNextcloudSyncProvider) + +void TestNextcloudSyncProvider::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +// --------------------------------------------------------------------------- +// canonicalizeServerBaseUrl +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testCanonicalize_addsHttpsWhenSchemeAbsent() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("cloud.example.com")), + QStringLiteral("https://cloud.example.com")); +} + +void TestNextcloudSyncProvider::testCanonicalize_acceptsHttps() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com")), + QStringLiteral("https://cloud.example.com")); +} + +void TestNextcloudSyncProvider::testCanonicalize_acceptsHttpForLoopback() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://localhost:8080")), + QStringLiteral("http://localhost:8080")); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://127.0.0.1")), + QStringLiteral("http://127.0.0.1")); + // IPv6 loopback. Qt URL serialization brackets the IPv6 host on output. + const QString ipv6 = NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://[::1]")); + QVERIFY2(!ipv6.isEmpty(), "IPv6 loopback (::1) must be accepted"); + QVERIFY2(ipv6.startsWith(QStringLiteral("http://")), + "IPv6 loopback canonicalization must preserve http scheme"); + QVERIFY2(ipv6.contains(QStringLiteral("::1")), + qPrintable(QStringLiteral("IPv6 host must survive canonicalization; got: %1").arg(ipv6))); +} + +void TestNextcloudSyncProvider::testCanonicalize_rejectsHttpForNonLoopback() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://cloud.example.com")), QString()); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://example.com")), QString()); + // 10.0.0.5 is private but not loopback. + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://10.0.0.5")), QString()); + // "localhost.evil.com" has "localhost" only as a label, not the entire host. + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("http://localhost.evil.com")), QString()); +} + +void TestNextcloudSyncProvider::testCanonicalize_rejectsNonHttpHttpsSchemes() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("ftp://example.com")), QString()); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("file:///etc/passwd")), QString()); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("javascript:alert(1)")), QString()); +} + +void TestNextcloudSyncProvider::testCanonicalize_stripsTrailingSlash() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com/")), + QStringLiteral("https://cloud.example.com")); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com///")), + QStringLiteral("https://cloud.example.com")); +} + +void TestNextcloudSyncProvider::testCanonicalize_preservesSubpath() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com/nextcloud")), + QStringLiteral("https://cloud.example.com/nextcloud")); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com/nextcloud/")), + QStringLiteral("https://cloud.example.com/nextcloud")); +} + +void TestNextcloudSyncProvider::testCanonicalize_stripsFragmentAndQuery() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com?foo=bar")), + QStringLiteral("https://cloud.example.com")); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("https://cloud.example.com#hash")), + QStringLiteral("https://cloud.example.com")); +} + +void TestNextcloudSyncProvider::testCanonicalize_emptyInput() +{ + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QString()), QString()); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral("")), QString()); + QCOMPARE(NextcloudSyncProvider::canonicalizeServerBaseUrl(QStringLiteral(" ")), QString()); +} + +void TestNextcloudSyncProvider::testCanonicalize_idempotent() +{ + const QStringList samples = { + QStringLiteral("cloud.example.com"), + QStringLiteral("https://cloud.example.com/"), + QStringLiteral("https://cloud.example.com/nextcloud/"), + QStringLiteral("http://localhost:8080"), + QStringLiteral("https://cloud.example.com?x=y#z"), + }; + for (const QString& s : samples) { + const QString once = NextcloudSyncProvider::canonicalizeServerBaseUrl(s); + const QString twice = NextcloudSyncProvider::canonicalizeServerBaseUrl(once); + QCOMPARE(twice, once); + } +} + +// --------------------------------------------------------------------------- +// isLoopbackHost +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testIsLoopbackHost_data() +{ + QTest::addColumn("host"); + QTest::addColumn("expected"); + + // The function takes a QUrl, so we encode the host into one. Use the + // http:// scheme so any host string is permissible to parse. + QTest::newRow("localhost lower") << QStringLiteral("localhost") << true; + QTest::newRow("LOCALHOST upper (case-insensitive)") << QStringLiteral("LOCALHOST") << true; + QTest::newRow("127.0.0.1") << QStringLiteral("127.0.0.1") << true; + QTest::newRow("127.255.255.255 (whole 127.0.0.0/8)") << QStringLiteral("127.255.255.255") << true; + QTest::newRow("::1 IPv6 loopback") << QStringLiteral("[::1]") << true; + QTest::newRow("localhost.example.com") << QStringLiteral("localhost.example.com") << false; + QTest::newRow("cloud.example.com") << QStringLiteral("cloud.example.com") << false; + QTest::newRow("10.0.0.1") << QStringLiteral("10.0.0.1") << false; + QTest::newRow("192.168.1.1") << QStringLiteral("192.168.1.1") << false; + QTest::newRow("empty host") << QString() << false; +} + +void TestNextcloudSyncProvider::testIsLoopbackHost() +{ + QFETCH(QString, host); + QFETCH(bool, expected); + + QUrl url; + url.setScheme(QStringLiteral("http")); + if (!host.isEmpty()) { + // Strip outer brackets for IPv6 — QUrl::setHost wants the bare form. + QString h = host; + if (h.startsWith(QLatin1Char('[')) && h.endsWith(QLatin1Char(']'))) { + h = h.mid(1, h.size() - 2); + } + url.setHost(h); + } + QCOMPARE(NextcloudSyncProvider::isLoopbackHost(url), expected); +} + +// --------------------------------------------------------------------------- +// validateServerUrl +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testValidateServerUrl_empty() +{ + QString sentinel = QStringLiteral("SENTINEL_UNCHANGED"); + + QString canon = sentinel; + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QString(), &canon), ServerUrlValidity::Empty); + QCOMPARE(canon, sentinel); + + canon = sentinel; + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral(" "), &canon), ServerUrlValidity::Empty); + QCOMPARE(canon, sentinel); +} + +void TestNextcloudSyncProvider::testValidateServerUrl_notSecure_beforeMalformed() +{ + // The ordering is locked: NotSecure must be reported BEFORE syntactic + // checks so the user sees the cleartext-policy banner rather than a + // generic "invalid URL" for a syntactically-fine but insecure URL. + QString sentinel = QStringLiteral("SENTINEL_UNCHANGED"); + QString canon = sentinel; + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("http://example.com"), &canon), + ServerUrlValidity::NotSecure); + QCOMPARE(canon, sentinel); +} + +void TestNextcloudSyncProvider::testValidateServerUrl_notSecure_loopbackOk() +{ + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("http://localhost")), ServerUrlValidity::Ok); +} + +void TestNextcloudSyncProvider::testValidateServerUrl_malformed_unsupportedScheme() +{ + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("ftp://example.com")), + ServerUrlValidity::Malformed); +} + +void TestNextcloudSyncProvider::testValidateServerUrl_malformed_noHost() +{ + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("https://")), ServerUrlValidity::Malformed); + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("https:///path")), ServerUrlValidity::Malformed); +} + +void TestNextcloudSyncProvider::testValidateServerUrl_ok_fillsCanonicalOut() +{ + QString canon; + QCOMPARE(NextcloudSyncProvider::validateServerUrl(QStringLiteral("cloud.example.com"), &canon), + ServerUrlValidity::Ok); + QCOMPARE(canon, QStringLiteral("https://cloud.example.com")); +} + +// --------------------------------------------------------------------------- +// normalizeRemotePath +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testNormalizeRemotePath_trimsWhitespace() +{ + QCOMPARE(NextcloudSyncProvider::normalizeRemotePath(QStringLiteral(" /foo.kdbx ")), + QStringLiteral("/foo.kdbx")); +} + +void TestNextcloudSyncProvider::testNormalizeRemotePath_NFC() +{ + // Decomposed form: 'e' + U+0301 (COMBINING ACUTE ACCENT) + QString decomposed = QStringLiteral("/caf"); + decomposed.append(QChar(0x0065)); // 'e' + decomposed.append(QChar(0x0301)); // combining acute + decomposed.append(QStringLiteral(".kdbx")); + + // Precomposed form: U+00E9 'é' + QString precomposed = QStringLiteral("/caf"); + precomposed.append(QChar(0x00E9)); + precomposed.append(QStringLiteral(".kdbx")); + + // Sanity: the two encodings must not be byte-equal before normalization. + QVERIFY(decomposed != precomposed); + + const QString normalized = NextcloudSyncProvider::normalizeRemotePath(decomposed); + QCOMPARE(QString::compare(normalized, precomposed, Qt::CaseSensitive), 0); +} + +void TestNextcloudSyncProvider::testNormalizeRemotePath_idempotent() +{ + const QStringList samples = { + QStringLiteral("/foo.kdbx"), + QStringLiteral(" /spaced.kdbx "), + QStringLiteral("/café.kdbx"), + QStringLiteral("/folder/sub/db.kdbx"), + }; + for (const QString& s : samples) { + const QString once = NextcloudSyncProvider::normalizeRemotePath(s); + const QString twice = NextcloudSyncProvider::normalizeRemotePath(once); + QCOMPARE(twice, once); + } +} + +// --------------------------------------------------------------------------- +// buildResourceUrl +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testBuildResourceUrl_composesCorrectly() +{ + const QUrl url = NextcloudSyncProvider::buildResourceUrl( + QStringLiteral("https://cloud.example.com"), QStringLiteral("alice"), QStringLiteral("/foo.kdbx")); + QCOMPARE(url.toString(QUrl::FullyEncoded), + QStringLiteral("https://cloud.example.com/remote.php/dav/files/alice/foo.kdbx")); +} + +void TestNextcloudSyncProvider::testBuildResourceUrl_encodesLoginNameAtSign() +{ + const QUrl url = NextcloudSyncProvider::buildResourceUrl(QStringLiteral("https://cloud.example.com"), + QStringLiteral("alice@example.com"), + QStringLiteral("/foo.kdbx")); + const QString s = url.toString(QUrl::FullyEncoded); + QVERIFY2(s.contains(QStringLiteral("/remote.php/dav/files/alice%40example.com/")), + qPrintable(QStringLiteral("Expected %40 (encoded '@') in login segment; got: %1").arg(s))); +} + +void TestNextcloudSyncProvider::testBuildResourceUrl_preservesSubpath() +{ + const QUrl url = NextcloudSyncProvider::buildResourceUrl( + QStringLiteral("https://cloud.example.com/nextcloud"), QStringLiteral("alice"), QStringLiteral("/foo.kdbx")); + QCOMPARE(url.toString(QUrl::FullyEncoded), + QStringLiteral("https://cloud.example.com/nextcloud/remote.php/dav/files/alice/foo.kdbx")); +} + +void TestNextcloudSyncProvider::testBuildResourceUrl_encodesSpacesInRemotePath() +{ + { + const QUrl url = NextcloudSyncProvider::buildResourceUrl(QStringLiteral("https://cloud.example.com"), + QStringLiteral("alice"), + QStringLiteral("/my passwords.kdbx")); + const QString s = url.toString(QUrl::FullyEncoded); + QVERIFY2(s.contains(QStringLiteral("/my%20passwords.kdbx")), + qPrintable(QStringLiteral("Expected '%20' for space; got: %1").arg(s))); + } + { + QString remotePath = QStringLiteral("/caf"); + remotePath.append(QChar(0x00E9)); // é (precomposed) + remotePath.append(QStringLiteral(".kdbx")); + const QUrl url = NextcloudSyncProvider::buildResourceUrl( + QStringLiteral("https://cloud.example.com"), QStringLiteral("alice"), remotePath); + const QString s = url.toString(QUrl::FullyEncoded); + QVERIFY2(s.contains(QStringLiteral("/caf%C3%A9.kdbx")), + qPrintable(QStringLiteral("Expected UTF-8 %%C3%%A9 for 'é'; got: %1").arg(s))); + } +} + +void TestNextcloudSyncProvider::testBuildResourceUrl_remotePathWithoutLeadingSlash_addsOne() +{ + const QUrl url = NextcloudSyncProvider::buildResourceUrl( + QStringLiteral("https://cloud.example.com"), QStringLiteral("alice"), QStringLiteral("foo.kdbx")); + const QString s = url.toString(QUrl::FullyEncoded); + QCOMPARE(s, QStringLiteral("https://cloud.example.com/remote.php/dav/files/alice/foo.kdbx")); + QVERIFY2(!s.contains(QStringLiteral("//foo.kdbx")), "must not double-slash before the filename"); +} + +// --------------------------------------------------------------------------- +// buildParamsFromConfig +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testBuildParamsFromConfig_extractsAllFields() +{ + NextcloudSyncProvider provider; + QJsonObject config; + config.insert(QStringLiteral("serverBaseUrl"), QStringLiteral("https://cloud.example.com")); + config.insert(QStringLiteral("remotePath"), QStringLiteral("/Passwords/db.kdbx")); + config.insert(QStringLiteral("loginName"), QStringLiteral("alice")); + config.insert(QStringLiteral("appPassword"), QStringLiteral("hunter2-app-password")); + config.insert(QStringLiteral("timeoutMsec"), 15000); + + QScopedPointer base(provider.buildParamsFromConfig(config)); + QVERIFY(base); + QCOMPARE(base->type, QStringLiteral("nextcloud")); + auto* p = dynamic_cast(base.data()); + QVERIFY2(p, "buildParamsFromConfig must return a NextcloudSyncParams"); + QCOMPARE(p->serverBaseUrl, QStringLiteral("https://cloud.example.com")); + QCOMPARE(p->remotePath, QStringLiteral("/Passwords/db.kdbx")); + QCOMPARE(p->loginName, QStringLiteral("alice")); + QCOMPARE(p->appPassword, QStringLiteral("hunter2-app-password")); + QCOMPARE(p->timeoutMsec, 15000); +} + +void TestNextcloudSyncProvider::testBuildParamsFromConfig_timeoutMsec_default() +{ + NextcloudSyncProvider provider; + QJsonObject config; + config.insert(QStringLiteral("serverBaseUrl"), QStringLiteral("https://cloud.example.com")); + config.insert(QStringLiteral("remotePath"), QStringLiteral("/db.kdbx")); + config.insert(QStringLiteral("loginName"), QStringLiteral("alice")); + config.insert(QStringLiteral("appPassword"), QStringLiteral("pw")); + // timeoutMsec deliberately omitted. + + QScopedPointer base(provider.buildParamsFromConfig(config)); + auto* p = dynamic_cast(base.data()); + QVERIFY(p); + QCOMPARE(p->timeoutMsec, 30000); +} + +// --------------------------------------------------------------------------- +// classifyError -- substring dispatch on locked banner fragments. The +// fragments below match the banner strings in NextcloudSyncProvider.cpp; +// the test locks the keyword -> ErrorKind mapping byte-for-byte. +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testClassifyError_authVariants() +{ + NextcloudSyncProvider provider; + + // 401 "Nextcloud authorization expired..." -> AuthExpired + QCOMPARE(provider.classifyError(QStringLiteral( + "Nextcloud authorization expired. Re-authorize in Database > Settings > Cloud Sync.")), + ErrorKind::AuthExpired); + // Case-insensitive substring match. + QCOMPARE(provider.classifyError(QStringLiteral("AUTHORIZATION EXPIRED")), ErrorKind::AuthExpired); + + // Manual-paste credential-rejection banner -> AuthExpired (intentional collapse). + QCOMPARE(provider.classifyError(QStringLiteral( + "Nextcloud rejected those credentials. Verify the username and app password.")), + ErrorKind::AuthExpired); + + // 403 -> Permission + QCOMPARE(provider.classifyError(QStringLiteral( + "Nextcloud denied access to this path. Verify the file path and your account permissions.")), + ErrorKind::Permission); + + // 404 trash banner -> NotFound + QCOMPARE(provider.classifyError(QStringLiteral( + "Database is in your Nextcloud trash. Restore it from Nextcloud Files, then try syncing again.")), + ErrorKind::NotFound); + // 404 testConnection banner -> NotFound (separate fragment) + QCOMPARE(provider.classifyError(QStringLiteral( + "Nextcloud could not find the configured remote path. Verify your settings.")), + ErrorKind::NotFound); + + // 412 -> Conflict + QCOMPARE(provider.classifyError(QStringLiteral( + "Remote file changed since last download. Re-sync to merge changes.")), + ErrorKind::Conflict); + + // 423 -> RateLimit + QCOMPARE(provider.classifyError(QStringLiteral("Nextcloud file is locked. Try again in a moment.")), + ErrorKind::RateLimit); + + // 507 -> Quota + QCOMPARE(provider.classifyError(QStringLiteral("Nextcloud server is out of storage. Free space and try again.")), + ErrorKind::Quota); + + // SSL handshake -> Network (no dedicated SslHandshake kind) + QCOMPARE(provider.classifyError(QStringLiteral( + "Nextcloud server's SSL certificate could not be verified. " + "Check that your server's certificate is valid and the chain is correctly configured.")), + ErrorKind::Network); + + // 5xx generic -> ServerError + QCOMPARE(provider.classifyError(QStringLiteral("Nextcloud server error (HTTP 502). Try again later.")), + ErrorKind::ServerError); + + // Unknown -> Other + QCOMPARE(provider.classifyError(QStringLiteral("something completely unrelated")), ErrorKind::Other); +} + +// --------------------------------------------------------------------------- +// isAuthorized -- four required fields. Data-driven: drop each one in turn, +// assert isAuthorized returns false; all-present returns true. +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testIsAuthorized_table_data() +{ + QTest::addColumn("hasServer"); + QTest::addColumn("hasLogin"); + QTest::addColumn("hasPassword"); + QTest::addColumn("hasRemotePath"); + QTest::addColumn("expected"); + + QTest::newRow("all present") << true << true << true << true << true; + QTest::newRow("missing serverBaseUrl") << false << true << true << true << false; + QTest::newRow("missing loginName") << true << false << true << true << false; + QTest::newRow("missing appPassword") << true << true << false << true << false; + QTest::newRow("missing remotePath") << true << true << true << false << false; + QTest::newRow("all missing") << false << false << false << false << false; +} + +void TestNextcloudSyncProvider::testIsAuthorized_table() +{ + QFETCH(bool, hasServer); + QFETCH(bool, hasLogin); + QFETCH(bool, hasPassword); + QFETCH(bool, hasRemotePath); + QFETCH(bool, expected); + + NextcloudSyncProvider provider; + QJsonObject config; + if (hasServer) { + config.insert(QStringLiteral("serverBaseUrl"), QStringLiteral("https://cloud.example.com")); + } + if (hasLogin) { + config.insert(QStringLiteral("loginName"), QStringLiteral("alice")); + } + if (hasPassword) { + config.insert(QStringLiteral("appPassword"), QStringLiteral("pw")); + } + if (hasRemotePath) { + config.insert(QStringLiteral("remotePath"), QStringLiteral("/db.kdbx")); + } + QCOMPARE(provider.isAuthorized(config), expected); +} + +// --------------------------------------------------------------------------- +// Entry-point validation -- these must reject BEFORE touching the network. +// We intentionally do NOT inject a QNetworkAccessManager: if validation lets +// the call fall through to ensureNam() / m_nam->get(), the test would either +// segfault (no NAM) or attempt a real DNS lookup, neither of which are what +// the public contract promises. +// --------------------------------------------------------------------------- + +void TestNextcloudSyncProvider::testDownload_rejectsEmptyServerBaseUrl() +{ + NextcloudSyncProvider provider; + NextcloudSyncParams params; + params.type = QStringLiteral("nextcloud"); + params.serverBaseUrl = QString(); + params.loginName = QStringLiteral("alice"); + params.appPassword = QStringLiteral("pw"); + params.remotePath = QStringLiteral("/db.kdbx"); + + const auto result = provider.download(¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("Nextcloud server URL"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("Expected server-URL error; got: %1").arg(result.errorMessage))); +} + +void TestNextcloudSyncProvider::testDownload_rejectsEmptyLoginName() +{ + NextcloudSyncProvider provider; + NextcloudSyncParams params; + params.type = QStringLiteral("nextcloud"); + params.serverBaseUrl = QStringLiteral("https://cloud.example.com"); + params.loginName = QString(); + params.appPassword = QStringLiteral("pw"); + params.remotePath = QStringLiteral("/db.kdbx"); + + const auto result = provider.download(¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("login name"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("Expected login-name error; got: %1").arg(result.errorMessage))); +} + +void TestNextcloudSyncProvider::testDownload_rejectsRelativeRemotePath() +{ + NextcloudSyncProvider provider; + NextcloudSyncParams params; + params.type = QStringLiteral("nextcloud"); + params.serverBaseUrl = QStringLiteral("https://cloud.example.com"); + params.loginName = QStringLiteral("alice"); + params.appPassword = QStringLiteral("pw"); + params.remotePath = QStringLiteral("foo.kdbx"); // no leading '/' + + const auto downloadResult = provider.download(¶ms); + QVERIFY(!downloadResult.success); + QVERIFY2(downloadResult.errorMessage.contains(QStringLiteral("must start with '/'")), + qPrintable(QStringLiteral("Expected 'must start with /' error; got: %1").arg(downloadResult.errorMessage))); + + // Equivalent for upload: same validation, surfaced from uploadImpl. + QTemporaryDir tmpDir; + QVERIFY(tmpDir.isValid()); + const QString localPath = tmpDir.path() + QStringLiteral("/db.kdbx"); + { + QFile f(localPath); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("payload"); + f.close(); + } + const auto uploadResult = provider.upload(localPath, ¶ms); + QVERIFY(!uploadResult.success); + QVERIFY2(uploadResult.errorMessage.contains(QStringLiteral("must start with '/'")), + qPrintable(QStringLiteral("Expected 'must start with /' error from upload; got: %1") + .arg(uploadResult.errorMessage))); +} + +void TestNextcloudSyncProvider::testUpload_rejectsEmptyServerBaseUrl() +{ + NextcloudSyncProvider provider; + NextcloudSyncParams params; + params.type = QStringLiteral("nextcloud"); + params.serverBaseUrl = QString(); + params.loginName = QStringLiteral("alice"); + params.appPassword = QStringLiteral("pw"); + params.remotePath = QStringLiteral("/db.kdbx"); + + QTemporaryDir tmpDir; + QVERIFY(tmpDir.isValid()); + const QString localPath = tmpDir.path() + QStringLiteral("/db.kdbx"); + { + QFile f(localPath); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("payload"); + f.close(); + } + + const auto result = provider.upload(localPath, ¶ms); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("Nextcloud server URL"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("Expected server-URL error; got: %1").arg(result.errorMessage))); +} + +void TestNextcloudSyncProvider::testUpload_rejectsMissingFile() +{ + NextcloudSyncProvider provider; + NextcloudSyncParams params; + params.type = QStringLiteral("nextcloud"); + params.serverBaseUrl = QStringLiteral("https://cloud.example.com"); + params.loginName = QStringLiteral("alice"); + params.appPassword = QStringLiteral("pw"); + params.remotePath = QStringLiteral("/db.kdbx"); + + // Path under a temp dir that we never create -- guaranteed not to exist. + QTemporaryDir tmpDir; + QVERIFY(tmpDir.isValid()); + const QString nonexistent = tmpDir.path() + QStringLiteral("/does-not-exist.kdbx"); + QVERIFY(!QFile::exists(nonexistent)); + + const auto result = provider.upload(nonexistent, ¶ms); + QVERIFY(!result.success); + // upload returns "Failed to open file for upload: " when QFile::open fails. + QVERIFY2(result.errorMessage.contains(QStringLiteral("Failed to open file"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("Expected open-failed error; got: %1").arg(result.errorMessage))); +} diff --git a/tests/TestNextcloudSyncProvider.h b/tests/TestNextcloudSyncProvider.h new file mode 100644 index 0000000000..5e6ec2540c --- /dev/null +++ b/tests/TestNextcloudSyncProvider.h @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTNEXTCLOUDSYNCPROVIDER_H +#define KEEPASSXC_TESTNEXTCLOUDSYNCPROVIDER_H + +#include + +class TestNextcloudSyncProvider : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + // canonicalizeServerBaseUrl + void testCanonicalize_addsHttpsWhenSchemeAbsent(); + void testCanonicalize_acceptsHttps(); + void testCanonicalize_acceptsHttpForLoopback(); + void testCanonicalize_rejectsHttpForNonLoopback(); + void testCanonicalize_rejectsNonHttpHttpsSchemes(); + void testCanonicalize_stripsTrailingSlash(); + void testCanonicalize_preservesSubpath(); + void testCanonicalize_stripsFragmentAndQuery(); + void testCanonicalize_emptyInput(); + void testCanonicalize_idempotent(); + + // isLoopbackHost + void testIsLoopbackHost_data(); + void testIsLoopbackHost(); + + // validateServerUrl + void testValidateServerUrl_empty(); + void testValidateServerUrl_notSecure_beforeMalformed(); + void testValidateServerUrl_notSecure_loopbackOk(); + void testValidateServerUrl_malformed_unsupportedScheme(); + void testValidateServerUrl_malformed_noHost(); + void testValidateServerUrl_ok_fillsCanonicalOut(); + + // normalizeRemotePath + void testNormalizeRemotePath_trimsWhitespace(); + void testNormalizeRemotePath_NFC(); + void testNormalizeRemotePath_idempotent(); + + // buildResourceUrl + void testBuildResourceUrl_composesCorrectly(); + void testBuildResourceUrl_encodesLoginNameAtSign(); + void testBuildResourceUrl_preservesSubpath(); + void testBuildResourceUrl_encodesSpacesInRemotePath(); + void testBuildResourceUrl_remotePathWithoutLeadingSlash_addsOne(); + + // buildParamsFromConfig + void testBuildParamsFromConfig_extractsAllFields(); + void testBuildParamsFromConfig_timeoutMsec_default(); + + // classifyError + void testClassifyError_authVariants(); + + // isAuthorized + void testIsAuthorized_table_data(); + void testIsAuthorized_table(); + + // Entry-point validation (no network) + void testDownload_rejectsEmptyServerBaseUrl(); + void testDownload_rejectsEmptyLoginName(); + void testDownload_rejectsRelativeRemotePath(); + void testUpload_rejectsEmptyServerBaseUrl(); + void testUpload_rejectsMissingFile(); +}; + +#endif // KEEPASSXC_TESTNEXTCLOUDSYNCPROVIDER_H diff --git a/tests/TestOAuthHttpServer.cpp b/tests/TestOAuthHttpServer.cpp new file mode 100644 index 0000000000..679535c64e --- /dev/null +++ b/tests/TestOAuthHttpServer.cpp @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestOAuthHttpServer.h" +#include "remotesync/OAuthHttpServer.h" + +#include +#include +#include +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestOAuthHttpServer) + +namespace +{ + // Connects to 127.0.0.1:port, writes requestBytes, accumulates response + // bytes via signal-driven event loop, exits when the server closes the + // connection. Returns true if any response bytes were received. + // + // Why signal-driven instead of waitForReadyRead in a loop: same-thread + // server means the response can land in our recv buffer between our + // write() and the first waitForReadyRead(), and waitForReadyRead blocks + // for NEW data only -- it then times out, costing seconds per test. + bool sendRequest(quint16 port, + const QByteArray& requestBytes, + QByteArray* responseOut = nullptr, + int timeoutMs = 3000) + { + QTcpSocket socket; + socket.connectToHost(QHostAddress::LocalHost, port); + if (!socket.waitForConnected(timeoutMs)) { + return false; + } + + QByteArray response; + QEventLoop loop; + QObject::connect(&socket, &QTcpSocket::readyRead, &loop, [&]() { + response.append(socket.readAll()); + }); + QObject::connect(&socket, &QTcpSocket::disconnected, &loop, &QEventLoop::quit); + QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit); + + socket.write(requestBytes); + loop.exec(); + + // Drain anything queued after the loop exited. + response.append(socket.readAll()); + + if (responseOut) { + *responseOut = response; + } + return !response.isEmpty(); + } +} // namespace + +void TestOAuthHttpServer::testStartStop() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + QVERIFY(server.isListening()); + QVERIFY(server.port() != 0); + + server.stop(); + QVERIFY(!server.isListening()); +} + +void TestOAuthHttpServer::testStartFailsOnPortConflict() +{ + // Occupy an OS-assigned port with a plain QTcpServer, then try to bind + // OAuthHttpServer to the same port. SO_REUSEADDR semantics differ across + // platforms, so on Windows this is reliable; on Linux the same exact port + // typically also fails for a second listen() without SO_REUSEPORT. If the + // OS surprises us and lets the second listen succeed, we skip rather than + // pretend the conflict-detection logic is broken. + QTcpServer blocker; + QVERIFY(blocker.listen(QHostAddress::LocalHost, 0)); + quint16 takenPort = blocker.serverPort(); + + OAuthHttpServer server; + bool started = server.start(takenPort); + if (started) { + // Platform allowed the dual-bind; not a real test failure. + server.stop(); + blocker.close(); + QSKIP("Platform allowed dual-bind of same port; conflict path not exercised"); + } + QVERIFY(!server.isListening()); + blocker.close(); +} + +void TestOAuthHttpServer::testValidCodeCallback_emitsAuthCodeReceived() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /?code=ABC123 HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(codeSpy.wait(2000) || codeSpy.count() == 1); + QCOMPARE(codeSpy.count(), 1); + QCOMPARE(codeSpy.takeFirst().at(0).toString(), QStringLiteral("ABC123")); + QCOMPARE(errorSpy.count(), 0); + + QVERIFY(response.startsWith("HTTP/1.1 200")); +} + +void TestOAuthHttpServer::testErrorCallback_emitsAuthError() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /?error=access_denied HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(errorSpy.wait(2000) || errorSpy.count() == 1); + QCOMPARE(errorSpy.count(), 1); + QCOMPARE(errorSpy.takeFirst().at(0).toString(), QStringLiteral("access_denied")); + QCOMPARE(codeSpy.count(), 0); + + // Per source, server returns 200 even on user-decline -- the OAuth + // callback URL is loaded by the user's browser and we want a clean tab. + QVERIFY(response.startsWith("HTTP/1.1 200")); +} + +void TestOAuthHttpServer::testStateMismatch_emits403AndAuthError() +{ + OAuthHttpServer server; + server.setExpectedState(QStringLiteral("expected_state_value")); + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /?code=X&state=wrong_state HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(errorSpy.wait(2000) || errorSpy.count() == 1); + QCOMPARE(errorSpy.count(), 1); + QCOMPARE(errorSpy.takeFirst().at(0).toString(), QStringLiteral("state_mismatch")); + QCOMPARE(codeSpy.count(), 0); + + QVERIFY(response.startsWith("HTTP/1.1 403")); +} + +void TestOAuthHttpServer::testStateMatch_succeeds() +{ + OAuthHttpServer server; + server.setExpectedState(QStringLiteral("expected_state_value")); + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /?code=X&state=expected_state_value HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(codeSpy.wait(2000) || codeSpy.count() == 1); + QCOMPARE(codeSpy.count(), 1); + QCOMPARE(codeSpy.takeFirst().at(0).toString(), QStringLiteral("X")); + QCOMPARE(errorSpy.count(), 0); + + QVERIFY(response.startsWith("HTTP/1.1 200")); +} + +void TestOAuthHttpServer::testNoCodeOrError_returns400() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(response.startsWith("HTTP/1.1 400")); + // Give any (unexpected) signal a moment to fire before asserting absence. + QTest::qWait(100); + QCOMPARE(codeSpy.count(), 0); + QCOMPARE(errorSpy.count(), 0); +} + +void TestOAuthHttpServer::testOversizedRequest_returns413() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + // MaxRequestSize is 8192. Build a request with a header value that pushes + // the total over the limit, ensuring the server hits the 413 branch. + QByteArray request = "GET /?code=ABC HTTP/1.1\r\nHost: localhost\r\nX-Padding: "; + request.append(QByteArray(9000, 'A')); + request.append("\r\n\r\n"); + + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response, 5000)); + + QVERIFY(response.startsWith("HTTP/1.1 413")); + QTest::qWait(100); + QCOMPARE(codeSpy.count(), 0); + QCOMPARE(errorSpy.count(), 0); +} + +void TestOAuthHttpServer::testDoubleCode_secondIgnored() +{ + OAuthHttpServer server; + QVERIFY(server.start(0)); + + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + + QByteArray firstRequest = "GET /?code=A HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray firstResponse; + QVERIFY(sendRequest(server.port(), firstRequest, &firstResponse)); + QVERIFY(codeSpy.wait(2000) || codeSpy.count() == 1); + QCOMPARE(codeSpy.count(), 1); + QCOMPARE(codeSpy.takeFirst().at(0).toString(), QStringLiteral("A")); + QVERIFY(firstResponse.startsWith("HTTP/1.1 200")); + + // Second valid callback -- the m_codeReceived guard must suppress it. + QByteArray secondRequest = "GET /?code=B HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray secondResponse; + QVERIFY(sendRequest(server.port(), secondRequest, &secondResponse)); + QTest::qWait(100); + QCOMPARE(codeSpy.count(), 0); // no NEW emissions since takeFirst above + QVERIFY(secondResponse.startsWith("HTTP/1.1 200")); +} + +void TestOAuthHttpServer::testStateClearedAfterStop() +{ + OAuthHttpServer server; + server.setExpectedState(QStringLiteral("some_state")); + QVERIFY(server.start(0)); + server.stop(); + + // Restart and send a code WITHOUT a state parameter. If stop() correctly + // cleared m_expectedState, the request succeeds. If state lingered, the + // server would 403 with state_mismatch. + QVERIFY(server.start(0)); + QSignalSpy codeSpy(&server, &OAuthHttpServer::authCodeReceived); + QSignalSpy errorSpy(&server, &OAuthHttpServer::authError); + + QByteArray request = "GET /?code=X HTTP/1.1\r\nHost: localhost\r\n\r\n"; + QByteArray response; + QVERIFY(sendRequest(server.port(), request, &response)); + + QVERIFY(codeSpy.wait(2000) || codeSpy.count() == 1); + QCOMPARE(codeSpy.count(), 1); + QCOMPARE(codeSpy.takeFirst().at(0).toString(), QStringLiteral("X")); + QCOMPARE(errorSpy.count(), 0); + QVERIFY(response.startsWith("HTTP/1.1 200")); +} diff --git a/tests/TestOAuthHttpServer.h b/tests/TestOAuthHttpServer.h new file mode 100644 index 0000000000..9fbc35a29a --- /dev/null +++ b/tests/TestOAuthHttpServer.h @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTOAUTHHTTPSERVER_H +#define KEEPASSX_TESTOAUTHHTTPSERVER_H + +#include + +class TestOAuthHttpServer : public QObject +{ + Q_OBJECT + +private slots: + void testStartStop(); + void testStartFailsOnPortConflict(); + void testValidCodeCallback_emitsAuthCodeReceived(); + void testErrorCallback_emitsAuthError(); + void testStateMismatch_emits403AndAuthError(); + void testStateMatch_succeeds(); + void testNoCodeOrError_returns400(); + void testOversizedRequest_returns413(); + void testDoubleCode_secondIgnored(); + void testStateClearedAfterStop(); +}; + +#endif // KEEPASSX_TESTOAUTHHTTPSERVER_H diff --git a/tests/TestRemoteSync.cpp b/tests/TestRemoteSync.cpp new file mode 100644 index 0000000000..5ae316449d --- /dev/null +++ b/tests/TestRemoteSync.cpp @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestRemoteSync.h" + +#include "config-keepassx-tests.h" +#include "config-keepassx.h" + +#include "mock/MockRemoteProcess.h" +#include "remotesync/CommandSyncProvider.h" +#include "remotesync/RemoteSyncParams.h" +#include "remotesync/RemoteSyncProvider.h" + +#include "gui/remote/RemoteHandler.h" +#include "gui/remote/RemoteProcess.h" + +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestRemoteSync) + +namespace +{ + // In-test stub: a concrete subclass that only overrides the pure virtuals, + // leaving every optional virtual at the base-class default. Used to assert + // the default behavior of those optional virtuals. + class StubProvider : public RemoteSyncProvider + { + public: + explicit StubProvider(QObject* parent = nullptr) + : RemoteSyncProvider(parent) + { + } + + RemoteHandler::RemoteResult download(const RemoteSyncParams*) override + { + return RemoteHandler::RemoteResult{true, {}, {}, {}, {}}; + } + RemoteHandler::RemoteResult upload(const QString&, const RemoteSyncParams*) override + { + return RemoteHandler::RemoteResult{true, {}, {}, {}, {}}; + } + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams*) override + { + return RemoteHandler::RemoteResult{true, {}, {}, {}, {}}; + } + void abort() override + { + } + QString displayName() const override + { + return QStringLiteral("stub"); + } + RemoteSyncParams* createParams() const override + { + return new RemoteSyncParams(); + } + }; +} // namespace + +void TestRemoteSync::cleanup() +{ + // Always clear the test override so tests can't pollute each other through + // the static factory-override slot. + RemoteSyncProvider::clearFactoryOverrideForTest(); +} + +void TestRemoteSync::testFactoryDispatch_command() +{ + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("command"), nullptr)); + QVERIFY(p); + QCOMPARE(p->displayName(), QStringLiteral("Command")); + QVERIFY(qobject_cast(p.data()) != nullptr); +} + +#ifdef KPXC_FEATURE_NETWORK +void TestRemoteSync::testFactoryDispatch_dropbox() +{ + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("dropbox"), nullptr)); + QVERIFY(p); + QCOMPARE(p->displayName(), QStringLiteral("Dropbox")); +} + +void TestRemoteSync::testFactoryDispatch_nextcloud() +{ + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("nextcloud"), nullptr)); + QVERIFY(p); + QCOMPARE(p->displayName(), QStringLiteral("Nextcloud")); +} +#endif + +void TestRemoteSync::testFactoryDispatch_unknown() +{ + // Source emits a qWarning for unknown types; suppress it so the test log + // is clean. + QTest::ignoreMessage(QtWarningMsg, "RemoteSyncProvider: Unknown provider type 'totally-bogus'"); + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("totally-bogus"), nullptr)); + QVERIFY(p.isNull()); +} + +void TestRemoteSync::testFactoryOverride_routesThroughOverride() +{ + RemoteSyncProvider::setFactoryOverrideForTest( + [](const QString&, QObject* parent) -> RemoteSyncProvider* { return new StubProvider(parent); }); + + { + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("anything"), nullptr)); + QVERIFY(p); + QVERIFY(dynamic_cast(p.data()) != nullptr); + // It also bypassed default dispatch entirely: a real "command" would have + // been a CommandSyncProvider, not a StubProvider. + QVERIFY(qobject_cast(p.data()) == nullptr); + } + + RemoteSyncProvider::clearFactoryOverrideForTest(); + + // After clearing, default dispatch must come back. + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("command"), nullptr)); + QVERIFY(p); + QVERIFY(qobject_cast(p.data()) != nullptr); +} + +void TestRemoteSync::testFactoryOverride_nullptrFallsThrough() +{ + // An override that returns nullptr means "I don't handle this; use the default". + // The factory must NOT short-circuit and return nullptr to the caller. + RemoteSyncProvider::setFactoryOverrideForTest( + [](const QString&, QObject*) -> RemoteSyncProvider* { return nullptr; }); + + QScopedPointer p(RemoteSyncProvider::create(QStringLiteral("command"), nullptr)); + QVERIFY(p); + QVERIFY(qobject_cast(p.data()) != nullptr); +} + +void TestRemoteSync::testDefaultVirtuals() +{ + StubProvider provider; + + // classifyError defaults to Other regardless of the message text. + QCOMPARE(provider.classifyError(QStringLiteral("anything")), RemoteSyncProvider::ErrorKind::Other); + QCOMPARE(provider.classifyError(QStringLiteral("401 unauthorized")), RemoteSyncProvider::ErrorKind::Other); + + // isAuthorized defaults to false (fail-closed). + QCOMPARE(provider.isAuthorized(QJsonObject{}), false); + + // applyRefreshedTokens defaults to no-op success. + QScopedPointer params(provider.createParams()); + QVERIFY(params); + QCOMPARE(provider.applyRefreshedTokens(QStringLiteral("anything"), params.data()), true); + + // buildParamsFromConfig defaults to createParams() (non-null). + QScopedPointer built(provider.buildParamsFromConfig(QJsonObject{})); + QVERIFY(built); + + // persistRefreshedTokens is a no-op; passing nullptr settings must not crash. + provider.persistRefreshedTokens(QStringLiteral("anything"), QStringLiteral("key"), nullptr); +} + +void TestRemoteSync::testCommand_createParams_returnsCommandSyncParams() +{ + CommandSyncProvider provider; + QScopedPointer params(provider.createParams()); + QVERIFY(params); + // Critical: download()/upload() static_cast the + // params they receive. If createParams() ever returned a bare RemoteSyncParams, + // downstream code would read undefined fields. Pin it. + QVERIFY(dynamic_cast(params.data()) != nullptr); +} + +void TestRemoteSync::testCommand_refreshAuth_isNoopSuccess() +{ + CommandSyncProvider provider; + // refreshAuth ignores its params arg for command providers, so nullptr is fine. + auto result = provider.refreshAuth(nullptr); + QCOMPARE(result.success, true); + QVERIFY(result.errorMessage.isEmpty()); + QVERIFY(result.stdOutput.isEmpty()); +} + +void TestRemoteSync::testCommand_displayName() +{ + CommandSyncProvider provider; + QCOMPARE(provider.displayName(), QStringLiteral("Command")); +} + +void TestRemoteSync::testCommand_downloadDelegatesToRemoteHandler() +{ + // Wire RemoteHandler to the mock process. MockRemoteProcess::start() copies + // a real kdbx file to the temp-file location, which is what RemoteHandler::download + // checks for existence + non-zero size to declare success. + const QString sourceDb = QStringLiteral(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabase.kdbx"); + RemoteHandler::setRemoteProcessFunc([sourceDb](QObject* parent) { + return QScopedPointer(new MockRemoteProcess(parent, sourceDb)); + }); + + CommandSyncProvider provider; + CommandSyncParams params; + params.name = QStringLiteral("test"); + params.downloadCommand = QStringLiteral("fake-cmd"); + params.downloadInput = QStringLiteral("foo"); + params.downloadTimeoutMsec = 10000; + + auto result = provider.download(¶ms); + + // Verifies CommandSyncProvider::download delegates to RemoteHandler via + // the mock process, which populates the temp file on success. + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QVERIFY(!result.filePath.isEmpty()); + QVERIFY(QFile::exists(result.filePath)); + + // Cleanup: remove the temp file the handler created and reset the + // process-factory back to the default so subsequent tests aren't poisoned. + QFile::remove(result.filePath); + RemoteHandler::setRemoteProcessFunc([](QObject* parent) { + return QScopedPointer(new RemoteProcess(parent)); + }); +} diff --git a/tests/TestRemoteSync.h b/tests/TestRemoteSync.h new file mode 100644 index 0000000000..b85e77006d --- /dev/null +++ b/tests/TestRemoteSync.h @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTREMOTESYNC_H +#define KEEPASSX_TESTREMOTESYNC_H + +#include "config-keepassx.h" + +#include + +class TestRemoteSync : public QObject +{ + Q_OBJECT + +private slots: + void cleanup(); + + // Factory + override seam + void testFactoryDispatch_command(); +#ifdef KPXC_FEATURE_NETWORK + void testFactoryDispatch_dropbox(); + void testFactoryDispatch_nextcloud(); +#endif + void testFactoryDispatch_unknown(); + void testFactoryOverride_routesThroughOverride(); + void testFactoryOverride_nullptrFallsThrough(); + + // Default virtuals on the base class + void testDefaultVirtuals(); + + // CommandSyncProvider + void testCommand_createParams_returnsCommandSyncParams(); + void testCommand_refreshAuth_isNoopSuccess(); + void testCommand_displayName(); + void testCommand_downloadDelegatesToRemoteHandler(); +}; + +#endif // KEEPASSX_TESTREMOTESYNC_H diff --git a/tests/TestSyncEngine.cpp b/tests/TestSyncEngine.cpp new file mode 100644 index 0000000000..faed8dd58f --- /dev/null +++ b/tests/TestSyncEngine.cpp @@ -0,0 +1,618 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestSyncEngine.h" + +#include +#include +#include +#include +#include + +#include "config-keepassx-tests.h" +#include "core/Database.h" +#include "crypto/Crypto.h" +#include "keys/CompositeKey.h" +#include "keys/PasswordKey.h" +#include "remotesync/RemoteSyncParams.h" +#include "remotesync/RemoteSyncProvider.h" +#include "remotesync/SyncEngine.h" +#include "util/TemporaryFile.h" + +QTEST_GUILESS_MAIN(TestSyncEngine) + +namespace +{ + const QString g_dbFile = QStringLiteral(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx"); + const QString g_dbFileDifferentPassword = + QStringLiteral(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabaseDifferentPassword.kdbx"); + + /// In-test test-double for RemoteSyncProvider. Records call counts and + /// returns canned results. Lives in the anonymous namespace so it can't + /// leak into other translation units. + class StubSyncProvider : public RemoteSyncProvider + { + public: + explicit StubSyncProvider(QObject* parent = nullptr) + : RemoteSyncProvider(parent) + { + } + + RemoteHandler::RemoteResult download(const RemoteSyncParams*) override + { + ++downloadCalls; + return downloadResult; + } + RemoteHandler::RemoteResult upload(const QString&, const RemoteSyncParams*) override + { + ++uploadCalls; + return uploadResult; + } + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams*) override + { + ++refreshAuthCalls; + return refreshAuthResult; + } + void abort() override + { + ++abortCalls; + } + QString displayName() const override + { + return QStringLiteral("Stub"); + } + RemoteSyncParams* createParams() const override + { + return new CommandSyncParams; + } + bool applyRefreshedTokens(const QString&, RemoteSyncParams*) override + { + ++applyTokenCalls; + return applyTokenResult; + } + + RemoteHandler::RemoteResult downloadResult{true, {}, {}, {}, {}}; + RemoteHandler::RemoteResult uploadResult{true, {}, {}, {}, {}}; + RemoteHandler::RemoteResult refreshAuthResult{true, {}, {}, {}, {}}; + int downloadCalls = 0; + int uploadCalls = 0; + int refreshAuthCalls = 0; + int abortCalls = 0; + int applyTokenCalls = 0; + bool applyTokenResult = true; + }; + + QSharedPointer makeKey(const QString& password) + { + auto key = QSharedPointer::create(); + key->addKey(QSharedPointer::create(password)); + return key; + } + + /// Open a fresh Database from a temp copy of `g_dbFile` (password "a"). + /// The TemporaryFile object outlives the function via the returned handle. + QSharedPointer openTempDb(TemporaryFile& tempFile) + { + bool copied = tempFile.copyFromFile(g_dbFile); + Q_ASSERT(copied); + Q_UNUSED(copied); + + auto db = QSharedPointer::create(); + QString error; + bool ok = db->open(tempFile.fileName(), makeKey(QStringLiteral("a")), &error); + Q_ASSERT(ok); + Q_UNUSED(ok); + return db; + } + + /// A SaveFn that does a real Database::save and reports success/error. + SyncEngine::SaveFn makeRealSaveFn(const QSharedPointer& db) + { + return [db](QString& errorMessage) { + return db->save(Database::Atomic, {}, &errorMessage); + }; + } + + /// Copy `source` to a freshly-generated path under QDir::tempPath() and + /// return that path. The caller is responsible for deletion (we can't use + /// TemporaryFile here because its destructor unconditionally removes the + /// file -- and several of these tests are explicitly checking that the + /// engine, not the test harness, removed the file). + QString copyToOwnedTempPath(const QString& source) + { + const QString path = QDir::tempPath() + QStringLiteral("/keepassxc-syncengine-test-") + + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".kdbx"); + QFile::remove(path); // best-effort, in case of collision + bool ok = QFile::copy(source, path); + Q_ASSERT(ok); + Q_UNUSED(ok); + return path; + } +} // namespace + +void TestSyncEngine::initTestCase() +{ + QVERIFY(Crypto::init()); + qRegisterMetaType("SyncEngine::State"); +} + +// --------------------------------------------------------------------------- +// State machine basics +// --------------------------------------------------------------------------- + +void TestSyncEngine::testInitialState_isIdle() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + QCOMPARE(engine.state(), SyncEngine::State::Idle); + QCOMPARE(engine.lastErrorKind(), RemoteHandler::ErrorKind::Other); +} + +void TestSyncEngine::testStartSync_whenAlreadyRunning_returnsFalseAndEmitsError() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + QScopedPointer params(provider.createParams()); + + // Re-enter startSync from inside the stateChanged slot while the first + // call is still in flight (state != Idle). This is the only way to hit + // the guard since startSync is synchronous start-to-finish. + bool secondCallReturn = true; + int secondCallAttempts = 0; + QObject::connect(&engine, &SyncEngine::stateChanged, [&](SyncEngine::State s) { + if (s == SyncEngine::State::Authenticating && secondCallAttempts == 0) { + ++secondCallAttempts; + secondCallReturn = engine.startSync(&provider, params.data()); + } + }); + + QSignalSpy errorSpy(&engine, &SyncEngine::syncError); + + QVERIFY(engine.startSync(&provider, params.data())); + QCOMPARE(secondCallAttempts, 1); + QCOMPARE(secondCallReturn, false); + QCOMPARE(errorSpy.count(), 1); +} + +void TestSyncEngine::testHappyPath_runsToCompletion() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + // First-sync convention: empty filePath means "no remote file yet". + provider.downloadResult = {true, {}, {}, {}, {}}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), true); + QCOMPARE(provider.refreshAuthCalls, 1); + QCOMPARE(provider.downloadCalls, 1); + QCOMPARE(provider.uploadCalls, 1); + QCOMPARE(engine.state(), SyncEngine::State::Idle); +} + +void TestSyncEngine::testFirstSync_skipsMerge() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, {}, {}, {}}; // first-sync (empty filePath) + QScopedPointer params(provider.createParams()); + + QSignalSpy stateSpy(&engine, &SyncEngine::stateChanged); + + QVERIFY(engine.startSync(&provider, params.data())); + + QList states; + for (const auto& args : stateSpy) { + states.append(args.at(0).value()); + } + QVERIFY2(!states.contains(SyncEngine::State::Merging), + "first-sync (empty filePath) must skip the Merging step entirely"); +} + +// --------------------------------------------------------------------------- +// Error paths +// --------------------------------------------------------------------------- + +void TestSyncEngine::testRefreshAuthFails_emitsSyncFinishedFalseAndSetsLastErrorKind() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + provider.refreshAuthResult = {false, "boom", {}, {}, {}, RemoteHandler::ErrorKind::AuthExpired}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + auto args = finishedSpy.takeFirst(); + QCOMPARE(args.at(0).toBool(), false); + QCOMPARE(args.at(1).toString(), QStringLiteral("boom")); + QCOMPARE(engine.lastErrorKind(), RemoteHandler::ErrorKind::AuthExpired); + QCOMPARE(provider.downloadCalls, 0); + QCOMPARE(provider.uploadCalls, 0); + QCOMPARE(engine.state(), SyncEngine::State::Idle); +} + +void TestSyncEngine::testDownloadFails_emitsSyncFinishedFalseAndSetsLastErrorKind() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + provider.downloadResult = {false, "net down", {}, {}, {}, RemoteHandler::ErrorKind::Network}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), false); + QCOMPARE(engine.lastErrorKind(), RemoteHandler::ErrorKind::Network); + QCOMPARE(provider.uploadCalls, 0); + QCOMPARE(engine.state(), SyncEngine::State::Idle); +} + +void TestSyncEngine::testUploadFails_emitsSyncFinishedFalseAndSetsLastErrorKind() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + // first-sync (no merge) -> save -> upload-fail + provider.uploadResult = {false, "server boom", {}, {}, {}, RemoteHandler::ErrorKind::ServerError}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), false); + QCOMPARE(engine.lastErrorKind(), RemoteHandler::ErrorKind::ServerError); + + // Local save MUST have succeeded -- upload failure does not roll the + // save back. Verify the .kdbx on disk is parseable with the original key. + auto reopened = QSharedPointer::create(); + QString err; + bool ok = reopened->open(tempDb.fileName(), makeKey(QStringLiteral("a")), &err); + QVERIFY2(ok, qPrintable(err)); +} + +void TestSyncEngine::testSaveFails_emitsSyncFinishedFalse() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + + SyncEngine::SaveFn failingSave = [](QString& err) { + err = QStringLiteral("disk full"); + return false; + }; + SyncEngine engine(db, failingSave); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, {}, {}, {}}; // first-sync -> straight to save + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + auto args = finishedSpy.takeFirst(); + QCOMPARE(args.at(0).toBool(), false); + QVERIFY(args.at(1).toString().contains(QStringLiteral("disk full"))); + QCOMPARE(provider.uploadCalls, 0); +} + +// --------------------------------------------------------------------------- +// Cancel semantics +// --------------------------------------------------------------------------- + +void TestSyncEngine::testCancel_betweenRefreshAuthAndDownload() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + QScopedPointer params(provider.createParams()); + + QObject::connect(&engine, &SyncEngine::stateChanged, [&](SyncEngine::State s) { + if (s == SyncEngine::State::Authenticating) { + engine.cancel(); + } + }); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + auto args = finishedSpy.takeFirst(); + QCOMPARE(args.at(0).toBool(), false); + QVERIFY(args.at(1).toString().contains(QStringLiteral("cancelled"), Qt::CaseInsensitive)); + QCOMPARE(provider.downloadCalls, 0); +} + +void TestSyncEngine::testCancel_inIdle_isNoop() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + QSignalSpy errorSpy(&engine, &SyncEngine::syncError); + QSignalSpy stateSpy(&engine, &SyncEngine::stateChanged); + + engine.cancel(); + + QCOMPARE(finishedSpy.count(), 0); + QCOMPARE(errorSpy.count(), 0); + QCOMPARE(stateSpy.count(), 0); + QCOMPARE(engine.state(), SyncEngine::State::Idle); +} + +// --------------------------------------------------------------------------- +// applyRefreshedTokens +// --------------------------------------------------------------------------- + +void TestSyncEngine::testApplyRefreshedTokens_failureSurfacesAsAuthError() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + // Non-empty stdOutput so SyncEngine attempts applyRefreshedTokens. + provider.refreshAuthResult = {true, {}, {}, QStringLiteral("malformed-json{"), {}}; + provider.applyTokenResult = false; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + auto args = finishedSpy.takeFirst(); + QCOMPARE(args.at(0).toBool(), false); + QVERIFY(args.at(1).toString().contains(QStringLiteral("Re-authorize"))); + QCOMPARE(provider.downloadCalls, 0); +} + +void TestSyncEngine::testRefreshedTokenData_signalFiresOnRefreshSuccess() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + const QString tokenJson = QStringLiteral("{\"accessToken\":\"new\"}"); + provider.refreshAuthResult = {true, {}, {}, tokenJson, {}}; + provider.applyTokenResult = true; + QScopedPointer params(provider.createParams()); + + QSignalSpy tokenSpy(&engine, &SyncEngine::refreshedTokenData); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(tokenSpy.count(), 1); + QCOMPARE(tokenSpy.takeFirst().at(0).toString(), tokenJson); +} + +// --------------------------------------------------------------------------- +// remoteDbNeedsKey (key mismatch) +// --------------------------------------------------------------------------- + +void TestSyncEngine::testRemoteDbNeedsKey_whenLocalAndRemoteKeysMismatch() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + // The "remote" file: a copy of SyncDatabaseDifferentPassword.kdbx + // (password "b"). Managed manually -- we need to assert it still exists + // after the engine's hand-off signal, so it must outlive any RAII wrapper. + const QString remotePath = copyToOwnedTempPath(g_dbFileDifferentPassword); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, remotePath, {}, {}}; + QScopedPointer params(provider.createParams()); + + QSignalSpy needsKeySpy(&engine, &SyncEngine::remoteDbNeedsKey); + + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(needsKeySpy.count(), 1); + const QString handedOffPath = needsKeySpy.takeFirst().at(0).toString(); + QCOMPARE(handedOffPath, remotePath); + // CRITICAL: file MUST still exist -- ownership was transferred to the + // receiver and the engine destructor must not race to delete it. + QVERIFY2(QFile::exists(handedOffPath), + "remoteDbNeedsKey hand-off must leave the file in place for the receiver"); + QCOMPARE(engine.state(), SyncEngine::State::Idle); + QCOMPARE(provider.uploadCalls, 0); + + QFile::remove(handedOffPath); +} + +// --------------------------------------------------------------------------- +// syncPreviousKey integration +// --------------------------------------------------------------------------- + +void TestSyncEngine::testClearSyncPreviousKey_onSuccessfulUpload() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + + auto prev = makeKey(QStringLiteral("previous")); + db->setSyncPreviousKey(prev); + QVERIFY(db->syncPreviousKey()); + + SyncEngine engine(db, makeRealSaveFn(db)); + StubSyncProvider provider; + provider.downloadResult = {true, {}, {}, {}, {}}; // first-sync, no merge + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), true); + QVERIFY2(!db->syncPreviousKey(), "successful upload must clear syncPreviousKey"); +} + +// --------------------------------------------------------------------------- +// Temp file cleanup +// --------------------------------------------------------------------------- + +void TestSyncEngine::testTempFileRemovedOnSuccess() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + // Make a real temp .kdbx (copy of NewDatabase.kdbx, same key "a") so the + // merge step opens cleanly. Manual path management -- we want to verify + // that the engine's cleanup() (not any RAII wrapper) removed it. + const QString downloadedPath = copyToOwnedTempPath(g_dbFile); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, downloadedPath, {}, {}}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), true); + QVERIFY2(!QFile::exists(downloadedPath), + "cleanup() must remove the downloaded temp file on success"); + QFile::remove(downloadedPath); // safety net if assertion fails +} + +void TestSyncEngine::testTempFileRemovedOnFailure() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + const QString downloadedPath = copyToOwnedTempPath(g_dbFile); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, downloadedPath, {}, {}}; + provider.uploadResult = {false, "boom", {}, {}, {}, RemoteHandler::ErrorKind::ServerError}; + QScopedPointer params(provider.createParams()); + + QSignalSpy finishedSpy(&engine, &SyncEngine::syncFinished); + QVERIFY(engine.startSync(&provider, params.data())); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.takeFirst().at(0).toBool(), false); + QVERIFY2(!QFile::exists(downloadedPath), + "cleanup() runs even on upload failure -- temp file must be removed"); + QFile::remove(downloadedPath); // safety net if assertion fails +} + +// --------------------------------------------------------------------------- +// State change signal sequence +// --------------------------------------------------------------------------- + +void TestSyncEngine::testStateChangeSequence_happyPath() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, {}, {}, {}}; // first-sync, no merge + QScopedPointer params(provider.createParams()); + + QSignalSpy stateSpy(&engine, &SyncEngine::stateChanged); + + QVERIFY(engine.startSync(&provider, params.data())); + + QList states; + for (const auto& args : stateSpy) { + states.append(args.at(0).value()); + } + const QList expected{ + SyncEngine::State::Authenticating, + SyncEngine::State::Downloading, + SyncEngine::State::Saving, + SyncEngine::State::Uploading, + SyncEngine::State::Idle, + }; + QCOMPARE(states, expected); +} + +void TestSyncEngine::testStateChangeSequence_withMerge() +{ + TemporaryFile tempDb; + auto db = openTempDb(tempDb); + SyncEngine engine(db, makeRealSaveFn(db)); + + // Real remote file (same key) so the merge step is reached. + const QString downloadedPath = copyToOwnedTempPath(g_dbFile); + + StubSyncProvider provider; + provider.downloadResult = {true, {}, downloadedPath, {}, {}}; + QScopedPointer params(provider.createParams()); + + QSignalSpy stateSpy(&engine, &SyncEngine::stateChanged); + + QVERIFY(engine.startSync(&provider, params.data())); + + QList states; + for (const auto& args : stateSpy) { + states.append(args.at(0).value()); + } + const QList expected{ + SyncEngine::State::Authenticating, + SyncEngine::State::Downloading, + SyncEngine::State::Merging, + SyncEngine::State::Saving, + SyncEngine::State::Uploading, + SyncEngine::State::Idle, + }; + QCOMPARE(states, expected); + + // Engine should have cleaned the temp file too. + QVERIFY(!QFile::exists(downloadedPath)); + QFile::remove(downloadedPath); // safety net if assertion fails +} diff --git a/tests/TestSyncEngine.h b/tests/TestSyncEngine.h new file mode 100644 index 0000000000..6af60832bf --- /dev/null +++ b/tests/TestSyncEngine.h @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTSYNCENGINE_H +#define KEEPASSX_TESTSYNCENGINE_H + +#include + +class TestSyncEngine : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + // State machine basics + void testInitialState_isIdle(); + void testStartSync_whenAlreadyRunning_returnsFalseAndEmitsError(); + void testHappyPath_runsToCompletion(); + void testFirstSync_skipsMerge(); + + // Error paths + void testRefreshAuthFails_emitsSyncFinishedFalseAndSetsLastErrorKind(); + void testDownloadFails_emitsSyncFinishedFalseAndSetsLastErrorKind(); + void testUploadFails_emitsSyncFinishedFalseAndSetsLastErrorKind(); + void testSaveFails_emitsSyncFinishedFalse(); + + // Cancel semantics + void testCancel_betweenRefreshAuthAndDownload(); + void testCancel_inIdle_isNoop(); + + // applyRefreshedTokens + void testApplyRefreshedTokens_failureSurfacesAsAuthError(); + void testRefreshedTokenData_signalFiresOnRefreshSuccess(); + + // remoteDbNeedsKey (key mismatch) + void testRemoteDbNeedsKey_whenLocalAndRemoteKeysMismatch(); + + // syncPreviousKey integration + void testClearSyncPreviousKey_onSuccessfulUpload(); + + // Temp file cleanup + void testTempFileRemovedOnSuccess(); + void testTempFileRemovedOnFailure(); + + // State change signal sequence + void testStateChangeSequence_happyPath(); + void testStateChangeSequence_withMerge(); +}; + +#endif // KEEPASSX_TESTSYNCENGINE_H diff --git a/tests/gui/CMakeLists.txt b/tests/gui/CMakeLists.txt index a549a8747e..ab11b71ba1 100644 --- a/tests/gui/CMakeLists.txt +++ b/tests/gui/CMakeLists.txt @@ -18,6 +18,16 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..) add_unit_test(NAME testgui SOURCES TestGui.cpp ../util/TemporaryFile.cpp ../mock/MockRemoteProcess.cpp LIBS ${TEST_LIBRARIES}) add_unit_test(NAME testguipixmaps SOURCES TestGuiPixmaps.cpp LIBS ${TEST_LIBRARIES}) +if(KPXC_FEATURE_NETWORK) + add_unit_test(NAME testcloudsyncwidget + SOURCES TestCloudSyncWidget.cpp + ../mock/MockDropboxLoginFlow.cpp + ../mock/MockDropboxSyncProvider.cpp + ../mock/MockNextcloudLoginFlow.cpp + ../mock/MockNextcloudSyncProvider.cpp + LIBS remotesync testsupport Qt6::Network Qt6::Widgets ${TEST_LIBRARIES}) +endif() + file(GLOB_RECURSE ATTACHMENTS_TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/attachments/*.cpp) add_unit_test(NAME testguiattachments SOURCES ${ATTACHMENTS_TEST_SOURCES} LIBS ${TEST_LIBRARIES}) include_directories(testguiattachments PRIVATE ${PROJECT_SOURCE_DIR}/src/gui/entry) diff --git a/tests/gui/TestCloudSyncWidget.cpp b/tests/gui/TestCloudSyncWidget.cpp new file mode 100644 index 0000000000..00bf45cc18 --- /dev/null +++ b/tests/gui/TestCloudSyncWidget.cpp @@ -0,0 +1,1849 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestCloudSyncWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "config-keepassx-tests.h" +#include "core/Config.h" +#include "core/Database.h" +#include "crypto/Crypto.h" +#include "gui/Application.h" +#include "gui/CategoryListWidget.h" +#include "gui/DatabaseTabWidget.h" +#include "gui/DatabaseWidget.h" +#include "gui/FileDialog.h" +#include "gui/MessageBox.h" +#include "gui/MessageWidget.h" +#include "gui/PasswordWidget.h" +#include "gui/dbsettings/DatabaseSettingsDialog.h" +#include "gui/remote/DatabaseSettingsWidgetCloudSync.h" +#include "gui/remote/RemoteSettings.h" +#include "gui/remote/dropbox/DropboxCloudSyncPage.h" +#include "gui/remote/nextcloud/NextcloudCloudSyncPage.h" +#include "mock/MockDropboxLoginFlow.h" +#include "mock/MockDropboxSyncProvider.h" +#include "mock/MockNextcloudLoginFlow.h" +#include "mock/MockNextcloudSyncProvider.h" +#include "remotesync/RemoteSyncProvider.h" + +int main(int argc, char* argv[]) +{ + QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); + QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); + Application app(argc, argv); + app.setApplicationName("KeePassXC"); + app.setQuitOnLastWindowClosed(false); + app.setAttribute(Qt::AA_Use96Dpi, true); + app.applyTheme(); + QTEST_DISABLE_KEYPAD_NAVIGATION + TestCloudSyncWidget tc; + QTEST_SET_MAIN_SOURCE_PATH + return QTest::qExec(&tc, argc, argv); +} + +void TestCloudSyncWidget::initTestCase() +{ + QVERIFY(Crypto::init()); + Config::createConfigFromFile(TemporaryFile::createTempConfigFile(), {}); + + QLocale::setDefault(QLocale::c()); + Application::bootstrap(); + + // Install the test factory override BEFORE constructing MainWindow. + // DatabaseSettingsWidgetCloudSync::registerPage calls + // RemoteSyncProvider::create("dropbox", ...) / ("nextcloud", ...) once + // during page construction (the result is stored on + // DropboxCloudSyncPage::m_dropboxProvider / NextcloudCloudSyncPage:: + // m_nextcloudProvider and never refreshed) -- so installing the override + // after `new MainWindow()` would leave the pages bound to real providers + // and make every subsequent Test Connection / Remove / sync click hit + // live HTTPS. + // + // The mocks delegate isAuthorized() to the real base class (gated on a + // kill-switch static), so the older paste-creds tests + // (CloudSettingSwitchProviderRemoveOldOne / CloudSettingMenuEntry) see + // identical "is this config authorized?" semantics to before -- only the + // network-fronted methods change. Override returns nullptr for unknown + // types so the default factory dispatch still produces real + // CommandSyncProvider for the script-sync path. + RemoteSyncProvider::setFactoryOverrideForTest([](const QString& type, QObject* parent) -> RemoteSyncProvider* { + if (type == QStringLiteral("dropbox")) { + return new MockDropboxSyncProvider(parent); + } + if (type == QStringLiteral("nextcloud")) { + return new MockNextcloudSyncProvider(parent); + } + return nullptr; + }); + + m_mainWindow.reset(new MainWindow()); + m_tabWidget = m_mainWindow->findChild("tabWidget"); + QVERIFY(m_tabWidget); + m_mainWindow->show(); + m_mainWindow->resize(1024, 768); +} + +void TestCloudSyncWidget::init() +{ + // Reset config and quiet down the first-run-only modals fired from + // MainWindow's ctor / openDatabase path. Mirrors TestGui::init. + config()->resetToDefaults(); + config()->set(Config::AutoSaveAfterEveryChange, false); + config()->set(Config::AutoSaveOnExit, false); + config()->set(Config::UpdateCheckMessageShown, true); + config()->set(Config::Security_QuickUnlock, false); + config()->set(Config::UseAtomicSaves, false); + config()->set(Config::GUI_ShowExpiredEntriesOnDatabaseUnlock, false); + config()->set(Config::OpenPreviousDatabasesOnStartup, false); + + // Copy the canonical test database (same one TestGui uses) into a + // temporary file so that each test mutates a fresh on-disk copy. The + // database has a real CompositeKey, so the database-key page's + // saveSettings short-circuits at its has-key early-return -- without + // this we'd land on its raw QMessageBox "no password" modal which is + // not wired to MessageBox::setNextAnswer and would hang the test. + auto origFilePath = QDir(KEEPASSX_TEST_DATA_DIR).absoluteFilePath("NewDatabase.kdbx"); + QVERIFY(m_dbFile.copyFromFile(origFilePath)); + m_dbFilePath = m_dbFile.fileName(); + + m_mainWindow->activateWindow(); + QApplication::processEvents(); + + fileDialog()->setNextFileName(m_dbFilePath); + triggerAction("actionDatabaseOpen"); + QApplication::processEvents(); + + m_dbWidget = m_tabWidget->currentDatabaseWidget(); + QVERIFY(m_dbWidget); + auto* databaseOpenWidget = m_dbWidget->findChild("databaseOpenWidget"); + QVERIFY(databaseOpenWidget); + auto* editPassword = + databaseOpenWidget->findChild("editPassword")->findChild("passwordEdit"); + QVERIFY(editPassword); + editPassword->setFocus(); + QTRY_VERIFY(editPassword->hasFocus()); + QTest::keyClicks(editPassword, "a"); + QTest::keyClick(editPassword, Qt::Key_Enter); + + QTRY_VERIFY(!m_dbWidget->isLocked()); + m_db = m_dbWidget->database(); + QApplication::processEvents(); + + openCloudSyncSettings(); +} + +// Open the Database Settings dialog through the same action a user would +// trigger from the menu, then navigate to the Cloud Sync category via the +// dialog's CategoryListWidget. Mirrors TestGui (triggerAction + +// setCurrentCategory at TestGui.cpp:199, :1717) rather than the +// programmatic shortcut DatabaseWidget::switchToCloudSyncSettings, so the +// test exercises the actionDatabaseSettings -> tabWidget::showDatabaseSettings +// -> DatabaseWidget::switchToDatabaseSettings wiring end to end. +void TestCloudSyncWidget::openCloudSyncSettings() +{ + triggerAction("actionDatabaseSettings"); + QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::DatabaseSettingsMode); + + auto* dialog = m_dbWidget->findChild("databaseSettingsDialog"); + QVERIFY(dialog); + QTRY_VERIFY(dialog->isVisible()); + + m_widget = dialog->findChild(); + QVERIFY(m_widget); + + // Navigate to the Cloud Sync category exactly the way TestGui navigates + // categories (TestGui.cpp:1717). The page index is discovered via + // EditWidget::pageIndex so the test stays correct when feature flags + // shift the page ordering (Browser/KeeShare/FdoSecrets between Cloud + // Sync and Maintenance). + auto* categoryList = dialog->findChild("categoryList"); + QVERIFY(categoryList); + const int cloudSyncIndex = dialog->pageIndex(m_widget); + QVERIFY(cloudSyncIndex >= 0); + categoryList->setCurrentCategory(cloudSyncIndex); + + // CRITICAL: prove the cloud-sync widget is actually rendered on screen, + // not just constructed under the QStackedWidget. A regression that leaves + // the page hidden (wrong setCurrentPage index, unparented widget, page + // ordering shift) would fail here -- without this assertion every other + // check in the file would still pass against an invisible widget. + QTRY_VERIFY(m_widget->isVisible()); + + auto* buttonBox = dialog->findChild(); + QVERIFY(buttonBox); + m_applyButton = buttonBox->button(QDialogButtonBox::Apply); + QVERIFY(m_applyButton); + QVERIFY(m_applyButton->isVisible()); +} + +void TestCloudSyncWidget::cleanup() +{ + if (m_tabWidget && m_tabWidget->isVisible() && m_dbWidget) { + // DO NOT save the database; saveAllSettings can mark it dirty (the + // General page always re-stamps SettingsChanged), so suppress the + // "save before close?" prompt by clearing the dirty flag first. + m_db->markAsClean(); + MessageBox::setNextAnswer(MessageBox::No); + triggerAction("actionDatabaseClose"); + QApplication::processEvents(); + MessageBox::setNextAnswer(MessageBox::NoButton); + delete m_dbWidget; + } + m_widget = nullptr; + m_applyButton = nullptr; + m_db.reset(); +} + +void TestCloudSyncWidget::cleanupTestCase() +{ + // Clear the factory override before any later test binary in the same + // process inherits a stale Dropbox->Mock binding. Symmetric with the + // initTestCase install. + RemoteSyncProvider::clearFactoryOverrideForTest(); + MockDropboxSyncProvider::setDownloadSourcePath(QString()); + MockDropboxSyncProvider::resetCallCounts(); + MockNextcloudSyncProvider::setDownloadSourcePath(QString()); + MockNextcloudSyncProvider::setIsAuthorizedOverride(true); + MockNextcloudSyncProvider::resetCallCounts(); + m_dbFile.remove(); +} + +void TestCloudSyncWidget::triggerAction(const QString& name) +{ + auto* action = m_mainWindow->findChild(name); + QVERIFY2(action, qPrintable(QString("Action doesn't exist: %1").arg(name))); + QVERIFY2(action->isEnabled(), qPrintable(QString("Action is disabled: %1").arg(name))); + action->trigger(); + QApplication::processEvents(); +} + +// Helper: lookup scoped to the dropboxPage subtree, falling back to the widget +// root for parent-level controls (providerComboBox, etc.). The two provider +// pages share several widget object names (remotePathEdit, authorizeButton, +// authStatusLabel, ...) so a top-level findChild would be ambiguous. +template static T* findInDropboxPage(QWidget* widget, const char* name) +{ + QWidget* dropboxPage = widget->findChild(QStringLiteral("dropboxPage")); + if (dropboxPage) { + T* hit = dropboxPage->template findChild(QString::fromLatin1(name)); + if (hit) { + return hit; + } + } + return widget->findChild(QString::fromLatin1(name)); +} + +template static T* findInNextcloudPage(QWidget* widget, const char* name) +{ + QWidget* nextcloudPage = widget->findChild(QStringLiteral("nextcloudPage")); + if (nextcloudPage) { + return nextcloudPage->template findChild(QString::fromLatin1(name)); + } + return nullptr; +} + +// Check that once a provider is set, exploring other providers' forms +// does not erase the current provider. +void TestCloudSyncWidget::CloudSettingNotImpactedWhileExploringOtherProviders() +{ + auto* comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + auto* dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + auto* nextcloudPage = m_widget->findChild(QStringLiteral("nextcloudPage")); + auto* appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + auto* dropboxRemotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + auto* authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + auto* authStatusLabel = findInDropboxPage(m_widget, "authStatusLabel"); + auto* serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + auto* nextcloudRemotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(nextcloudPage); + QVERIFY(appKeyEdit); + QVERIFY(dropboxRemotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(authStatusLabel); + QVERIFY(serverBaseUrlEdit); + QVERIFY(nextcloudRemotePathEdit); + + // ---- Step 1: initial state ------------------------------------------- + // Default provider is Dropbox, no fields filled, Apply button grayed. + QCOMPARE(comboBox->currentIndex(), 0); + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + QVERIFY(appKeyEdit->text().isEmpty()); + QVERIFY(dropboxRemotePathEdit->text().isEmpty()); + QCOMPARE(dropboxPage->isModified(), false); + // CRITICAL: literal Apply button, not a proxy. If the initial empty + // loadFromConfig accidentally fires modified() (e.g. a future change + // drops the QSignalBlockers around setText), this assertion catches it. + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 2: fill conf, authorize, apply ----------------------------- + QTest::keyClicks(appKeyEdit, QStringLiteral("test-app-key")); + // CRITICAL: typing into a field must enable Apply (the dirty signal + // path: appKeyEdit textChanged -> markModified -> emit modified() -> + // settingsModified -> setModified(true) -> Apply enabled). QTRY because + // textChanged may deliver across an event-loop boundary. + QTRY_VERIFY(m_applyButton->isEnabled()); + + QTest::keyClicks(dropboxRemotePathEdit, QStringLiteral("/test/path.kdbx")); + QTRY_VERIFY(m_applyButton->isEnabled()); + + // Inject the login-flow mock BEFORE clicking Authorize so the page's + // lazy-construct in ensureLoginFlow is a no-op (the test seam wins). + auto* loginFlow = new MockDropboxLoginFlow(); + loginFlow->setCannedTokens(QStringLiteral("tok-123"), QStringLiteral("rtok-456"), 99999999999LL); + loginFlow->setNextStartOutcome(MockDropboxLoginFlow::StartOutcome::Completed); + dropboxPage->setLoginFlowForTest(loginFlow); + + QVERIFY(authorizeButton->isVisible()); + QVERIFY(authorizeButton->isEnabled()); + QTest::mouseClick(authorizeButton, Qt::LeftButton); + QTRY_VERIFY(authStatusLabel->text().contains(QStringLiteral("Authorized"))); + // CRITICAL: authorization completion calls mergeAndPersistTokens which + // emits modified() again. Apply must stay enabled until the user clicks + // it -- otherwise the freshly-acquired tokens would be unsaveable. + QTRY_VERIFY(m_applyButton->isEnabled()); + + // "Tu apply" -- actually click the Apply button (do NOT call + // saveSettings() directly). This exercises the real button-click -> + // EditWidget::apply() -> DatabaseSettingsDialog::applySettings -> + // saveAllSettings -> setModified(false) chain. + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + // CRITICAL: a successful Apply must re-gray the button. If the handler's + // setModified(false) is short-circuited (e.g. saveSettings returns false) + // or a stale modified() fires after, this catches it. Plain QVERIFY + // (not QTRY) -- saveAllSettings -> setModified(false) is synchronous on + // the Apply mouseClick, matching TestGui:637. + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 3: switch to Nextcloud ------------------------------------- + comboBox->setCurrentIndex(1); + QCOMPARE(comboBox->currentText(), QStringLiteral("Nextcloud")); + // Nextcloud page has never been edited -- both fields empty. + QVERIFY(serverBaseUrlEdit->text().isEmpty()); + QVERIFY(nextcloudRemotePathEdit->text().isEmpty()); + QCOMPARE(nextcloudPage->isModified(), false); + // CRITICAL: switching providers in the combobox is NOT a user edit. If + // onProviderChanged ever emits modified() (or any side effect that + // bubbles to settingsModified), Apply re-enables here and we trip. + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 4: switch back to Dropbox ---------------------------------- + comboBox->setCurrentIndex(0); + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + // The whole point of this test: typed values must survive the round-trip + // through the Nextcloud page. If a future change wipes m_config or + // re-loads the page from RemoteSettings on every switch, this fails. + QCOMPARE(appKeyEdit->text(), QStringLiteral("test-app-key")); + QCOMPARE(dropboxRemotePathEdit->text(), QStringLiteral("/test/path.kdbx")); + // CRITICAL: Apply must still be grayed -- coming back to Dropbox is also + // not a user edit. Same contract as step 3. + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 5: persisted JSON ------------------------------------------ + // Fresh RemoteSettings reads from the database's CustomData -- closes + // the loop from "I clicked Apply" all the way to bytes on disk. + RemoteSettings verifySettings(m_db, nullptr); + QJsonObject config = + verifySettings.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")); + QCOMPARE(config[QStringLiteral("type")].toString(), QStringLiteral("dropbox")); + QCOMPARE(config[QStringLiteral("name")].toString(), QStringLiteral("dropbox-default")); + QCOMPARE(config[QStringLiteral("appKey")].toString(), QStringLiteral("test-app-key")); + QCOMPARE(config[QStringLiteral("remotePath")].toString(), QStringLiteral("/test/path.kdbx")); + QCOMPARE(config[QStringLiteral("accessToken")].toString(), QStringLiteral("tok-123")); + QCOMPARE(config[QStringLiteral("refreshToken")].toString(), QStringLiteral("rtok-456")); + QCOMPARE(verifySettings.activeProvider(), QStringLiteral("dropbox")); + // No Nextcloud record persisted -- we only ever filled Dropbox. + QVERIFY(verifySettings.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")) + .isEmpty()); +} + +// Check that switching to a different provider, filling it, and clicking Apply +// removes the previously-configured provider from the database JSON. Encodes +// the single-provider model enforced by +// DatabaseSettingsWidgetCloudSync::saveSettings: when the active page is +// authorized, every other page's config is wiped from RemoteSettings and the +// active page becomes the database's only cloud-sync provider. +void TestCloudSyncWidget::CloudSettingSwitchProviderRemoveOldOne() +{ + auto* comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + auto* dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + auto* nextcloudPage = m_widget->findChild(QStringLiteral("nextcloudPage")); + auto* appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + auto* dropboxRemotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + auto* authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + auto* serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + auto* nextcloudRemotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + auto* loginNameEdit = findInNextcloudPage(m_widget, "loginNameEdit"); + auto* appPasswordEdit = findInNextcloudPage(m_widget, "appPasswordEdit"); + auto* appPasswordGroupBox = findInNextcloudPage(m_widget, "appPasswordGroupBox"); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(nextcloudPage); + QVERIFY(appKeyEdit); + QVERIFY(dropboxRemotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(serverBaseUrlEdit); + QVERIFY(nextcloudRemotePathEdit); + QVERIFY(loginNameEdit); + QVERIFY(appPasswordEdit); + QVERIFY(appPasswordGroupBox); + + // ---- Step 1: configure + Apply Dropbox ------------------------------- + QTest::keyClicks(appKeyEdit, QStringLiteral("test-app-key")); + QTest::keyClicks(dropboxRemotePathEdit, QStringLiteral("/old.kdbx")); + + auto* loginFlow = new MockDropboxLoginFlow(); + loginFlow->setCannedTokens(QStringLiteral("dropbox-tok"), QStringLiteral("dropbox-rtok"), 99999999999LL); + loginFlow->setNextStartOutcome(MockDropboxLoginFlow::StartOutcome::Completed); + dropboxPage->setLoginFlowForTest(loginFlow); + QVERIFY(authorizeButton->isVisible()); + QVERIFY(authorizeButton->isEnabled()); + QTest::mouseClick(authorizeButton, Qt::LeftButton); + QTRY_VERIFY(m_applyButton->isEnabled()); + + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + QVERIFY(!m_applyButton->isEnabled()); + + // Intermediate state: Dropbox persisted, no Nextcloud record yet. + // Asserting this in-line proves that step 2's wipe assertion later is + // actually testing "Dropbox WAS there before Apply" -- not just "Dropbox + // was never there to begin with." + { + RemoteSettings rs(m_db, nullptr); + QJsonObject dropboxConfig = + rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")); + QCOMPARE(dropboxConfig[QStringLiteral("type")].toString(), QStringLiteral("dropbox")); + QCOMPARE(dropboxConfig[QStringLiteral("appKey")].toString(), QStringLiteral("test-app-key")); + QCOMPARE(dropboxConfig[QStringLiteral("remotePath")].toString(), QStringLiteral("/old.kdbx")); + QCOMPARE(dropboxConfig[QStringLiteral("accessToken")].toString(), QStringLiteral("dropbox-tok")); + QCOMPARE(dropboxConfig[QStringLiteral("refreshToken")].toString(), QStringLiteral("dropbox-rtok")); + QCOMPARE(rs.activeProvider(), QStringLiteral("dropbox")); + QVERIFY(rs.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")).isEmpty()); + } + + // ---- Step 2: switch to Nextcloud and fill it ------------------------- + comboBox->setCurrentIndex(1); + QCOMPARE(comboBox->currentText(), QStringLiteral("Nextcloud")); + // Switching providers is not a user edit -- Apply stays grayed. + QVERIFY(!m_applyButton->isEnabled()); + + QTest::keyClicks(serverBaseUrlEdit, QStringLiteral("https://cloud.example.com")); + // CRITICAL: first user edit after the page-switch must enable Apply. + QTRY_VERIFY(m_applyButton->isEnabled()); + QTest::keyClicks(nextcloudRemotePathEdit, QStringLiteral("/Passwords/Database.kdbx")); + + // Expand the App Password sub-panel and type the credentials. This is + // the "paste-without-Authorize" path the page explicitly supports: + // saveToConfig reads loginNameEdit / appPasswordEdit when non-empty, + // bypassing onAppPasswordAuthorizeClicked (which would call + // NextcloudSyncProvider::testConnection -- real network, no mock + // available on this branch). Filling the line edits matches what a + // user would type; what we skip is the optional pre-flight test, not + // the persistence path being verified. + appPasswordGroupBox->setChecked(true); + QTest::keyClicks(loginNameEdit, QStringLiteral("alice")); + QTest::keyClicks(appPasswordEdit, QStringLiteral("app-pw-123")); + + // ---- Step 3: Apply -- the single-provider wipe must fire here -------- + QTRY_VERIFY(m_applyButton->isEnabled()); + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 4: final on-disk state ------------------------------------- + // The point of the test. The wipe is gated on + // probe->isAuthorized(config) being true for the new active page; that + // is why we fill all 4 Nextcloud-isAuthorized fields above (loginName + + // appPassword + serverBaseUrl + remotePath). + RemoteSettings verifySettings(m_db, nullptr); + + // CRITICAL: Dropbox config must be gone. Regressions this catches: + // * dropping the if(authorized) wipe loop in saveSettings, + // * wiping only m_remoteSettings in-memory but not calling saveSettings, + // * setProviderConfig running before removeProviderConfig on the + // wrong provider key (would leave both entries on disk). + QVERIFY(verifySettings.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")).isEmpty()); + + QJsonObject nextcloudConfig = + verifySettings.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")); + QCOMPARE(nextcloudConfig[QStringLiteral("type")].toString(), QStringLiteral("nextcloud")); + QCOMPARE(nextcloudConfig[QStringLiteral("name")].toString(), QStringLiteral("nextcloud-default")); + QCOMPARE(nextcloudConfig[QStringLiteral("serverBaseUrl")].toString(), QStringLiteral("https://cloud.example.com")); + QCOMPARE(nextcloudConfig[QStringLiteral("remotePath")].toString(), QStringLiteral("/Passwords/Database.kdbx")); + QCOMPARE(nextcloudConfig[QStringLiteral("loginName")].toString(), QStringLiteral("alice")); + QCOMPARE(nextcloudConfig[QStringLiteral("appPassword")].toString(), QStringLiteral("app-pw-123")); + QCOMPARE(verifySettings.activeProvider(), QStringLiteral("nextcloud")); + + // ---- Step 5: the displaced Dropbox page's UI was also reset ---------- + // saveSettings calls loadFromConfig({}) on every non-active page when + // the new active page is authorized. Switching back to Dropbox here + // must show empty fields, NOT the values typed before the wipe -- + // otherwise the UI would visually contradict the on-disk single-provider + // state (the comment at DatabaseSettingsWidgetCloudSync.cpp:215-223 + // calls out exactly this case). + comboBox->setCurrentIndex(0); + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + QVERIFY(appKeyEdit->text().isEmpty()); + QVERIFY(dropboxRemotePathEdit->text().isEmpty()); + QCOMPARE(dropboxPage->isModified(), false); + QVERIFY(!m_applyButton->isEnabled()); +} + +// Check that the Database > Remote Sync menu's "Trigger Sync" +// entry tracks the currently-configured cloud provider: +// * No provider configured -> no Trigger entry at all. +// * Nextcloud configured + Apply -> "Trigger Nextcloud Sync" appears, +// "Trigger Dropbox Sync" must NOT appear. +// * Switch to Dropbox + Apply -> "Trigger Dropbox Sync" appears, +// "Trigger Nextcloud Sync" must NOT appear (the previous entry was +// wiped along with the old provider config under the single-provider +// model). +// +// The exact string comes from MainWindow.cpp:1290 -- +// tr("Trigger %1 Sync").arg(providerName) +// where providerName is RemoteSyncProvider::displayName() ("Dropbox" or +// "Nextcloud", untranslated brand identifiers). +void TestCloudSyncWidget::CloudSettingMenuEntry() +{ + // The cloud-sync widget's Apply path writes to m_db's CustomData; that + // fires Database::modified, which DatabaseWidget connects to its own + // onDatabaseModified slot (DatabaseWidget.cpp:1565), and that slot calls + // m_remoteSettings->loadSettings() to reread. So the menu reads the + // post-Apply state on the next updateRemoteSyncMenuEntries call. + auto* menuRemoteSync = m_mainWindow->findChild(QStringLiteral("menuRemoteSync")); + QVERIFY(menuRemoteSync); + + // CRITICAL: this lambda reads the real QMenu the user sees. It does NOT + // call isCloudSyncAuthorized() or getCloudSyncProviderDisplayName() + // directly -- if a regression broke the link between those contracts + // and the visible menu (e.g. updateRemoteSyncMenuEntries stops being + // wired to aboutToShow, or it stops calling addAction), this test + // would fail where a contract-level test would still pass. The + // isVisible() filter matches what a user can actually click -- an + // action that's in the QMenu's action list but hidden does not appear + // in the popup. + auto hasTriggerEntryFor = [menuRemoteSync](const QString& providerDisplayName) { + const QString expected = QStringLiteral("Trigger %1 Sync").arg(providerDisplayName); + for (auto* action : menuRemoteSync->actions()) { + if (action->isVisible() && action->text() == expected) { + return true; + } + } + return false; + }; + + // ---- Step 1: fresh DB, no cloud sync configured ---------------------- + // Trigger the production rebuild path by actually popping the menu -- + // the QMenu::aboutToShow signal that MainWindow.cpp:175 wires to the + // (private) updateRemoteSyncMenuEntries slot fires inside popup(). + // Same pattern as TestGui::prepareAndTriggerRemoteSync. + menuRemoteSync->popup({0, 0}); + QApplication::processEvents(); + menuRemoteSync->close(); + // CRITICAL: neither Trigger entry must be present before any provider + // is configured. If isCloudSyncAuthorized returns true for an empty + // RemoteSettings, this fails. + QVERIFY(!hasTriggerEntryFor(QStringLiteral("Dropbox"))); + QVERIFY(!hasTriggerEntryFor(QStringLiteral("Nextcloud"))); + + // ---- Step 2: configure Nextcloud via the cloud-sync widget + Apply -- + auto* comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + auto* serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + auto* nextcloudRemotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + auto* loginNameEdit = findInNextcloudPage(m_widget, "loginNameEdit"); + auto* appPasswordEdit = findInNextcloudPage(m_widget, "appPasswordEdit"); + auto* appPasswordGroupBox = findInNextcloudPage(m_widget, "appPasswordGroupBox"); + QVERIFY(comboBox); + QVERIFY(serverBaseUrlEdit); + QVERIFY(nextcloudRemotePathEdit); + QVERIFY(loginNameEdit); + QVERIFY(appPasswordEdit); + QVERIFY(appPasswordGroupBox); + + comboBox->setCurrentIndex(1); + QTest::keyClicks(serverBaseUrlEdit, QStringLiteral("https://cloud.example.com")); + QTest::keyClicks(nextcloudRemotePathEdit, QStringLiteral("/Passwords/Database.kdbx")); + appPasswordGroupBox->setChecked(true); + QTest::keyClicks(loginNameEdit, QStringLiteral("alice")); + QTest::keyClicks(appPasswordEdit, QStringLiteral("app-pw-123")); + QTRY_VERIFY(m_applyButton->isEnabled()); + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + + // Database::markAsModified starts a 150ms QTimer (Database.cpp:1074) + // before emitting modified(); DatabaseWidget::onDatabaseModified, the + // slot that reloads m_remoteSettings, runs after that signal fires. + // QTRY_VERIFY polls until the dbWidget actually reports the cloud-sync + // provider as authorized -- no fixed delay (TestGui pattern: zero + // qWait/qSleep usages in the entire file). + QTRY_VERIFY(m_dbWidget->isCloudSyncAuthorized()); + QCOMPARE(m_dbWidget->getCloudSyncProviderDisplayName(), QStringLiteral("Nextcloud")); + // Pop the menu for real: aboutToShow fires from inside QMenu::popup, + // which is what MainWindow.cpp:175 wires to updateRemoteSyncMenuEntries. + menuRemoteSync->popup({0, 0}); + QApplication::processEvents(); + menuRemoteSync->close(); + // CRITICAL: this is the visible menu state on the user's screen after + // they click Apply. Regressions caught: the Apply path not persisting + // an "authorized" config, dbWidget->m_remoteSettings not reloading on + // Database::modified, or updateRemoteSyncMenuEntries not honoring the + // provider's displayName. + QVERIFY(hasTriggerEntryFor(QStringLiteral("Nextcloud"))); + QVERIFY(!hasTriggerEntryFor(QStringLiteral("Dropbox"))); + + // ---- Step 3: switch to Dropbox + Apply ------------------------------- + comboBox->setCurrentIndex(0); + auto* dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + auto* appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + auto* dropboxRemotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + auto* authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + QVERIFY(dropboxPage); + QVERIFY(appKeyEdit); + QVERIFY(dropboxRemotePathEdit); + QVERIFY(authorizeButton); + + QTest::keyClicks(appKeyEdit, QStringLiteral("test-app-key")); + QTest::keyClicks(dropboxRemotePathEdit, QStringLiteral("/test/path.kdbx")); + + auto* loginFlow = new MockDropboxLoginFlow(); + loginFlow->setCannedTokens(QStringLiteral("tok-123"), QStringLiteral("rtok-456"), 99999999999LL); + loginFlow->setNextStartOutcome(MockDropboxLoginFlow::StartOutcome::Completed); + dropboxPage->setLoginFlowForTest(loginFlow); + // Dropbox page was re-shown by the combobox switch above; assert that + // before clicking into it so a regression that keeps Nextcloud visible + // (or hides both) fails here rather than on a confusing downstream check. + QTRY_VERIFY(dropboxPage->isVisible()); + QVERIFY(authorizeButton->isVisible()); + QVERIFY(authorizeButton->isEnabled()); + QTest::mouseClick(authorizeButton, Qt::LeftButton); + QTRY_VERIFY(m_applyButton->isEnabled()); + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + + // Same 150ms-timer dance as step 2 -- poll instead of waiting a fixed + // duration. The displayName flip from "Nextcloud" to "Dropbox" is the + // observable that proves dbWidget reloaded its RemoteSettings. + QTRY_COMPARE(m_dbWidget->getCloudSyncProviderDisplayName(), QStringLiteral("Dropbox")); + QVERIFY(m_dbWidget->isCloudSyncAuthorized()); + // Pop the menu for real (aboutToShow fires inside popup) -- same as + // the step 2 invocation. + menuRemoteSync->popup({0, 0}); + QApplication::processEvents(); + menuRemoteSync->close(); + QVERIFY(hasTriggerEntryFor(QStringLiteral("Dropbox"))); + // CRITICAL: switching providers must replace the menu entry, not stack + // them. updateRemoteSyncMenuEntries' menuRemoteSync->clear() at the top + // of the slot is what enforces this; if it gets dropped or guarded out, + // the "Trigger Nextcloud Sync" entry from step 2 would still be there. + QVERIFY(!hasTriggerEntryFor(QStringLiteral("Nextcloud"))); +} + +// Click OK on the database settings dialog (the QDialogButtonBox::Ok button) +// and wait for the dialog to actually close. Going through the real button +// click exercises buttonBox accepted() -> EditWidget::accepted -> +// DatabaseSettingsDialog::save -> saveAllSettings + editFinished(true) -> +// DatabaseWidget::switchToMainView. Calling save() directly would skip the +// button-state-machine half of that contract. +void TestCloudSyncWidget::closeDatabaseSettingsViaOk() +{ + auto* dialog = m_dbWidget->findChild("databaseSettingsDialog"); + QVERIFY(dialog); + auto* buttonBox = dialog->findChild(); + QVERIFY(buttonBox); + auto* okButton = buttonBox->button(QDialogButtonBox::Ok); + QVERIFY(okButton); + QVERIFY(okButton->isVisible()); + QTest::mouseClick(okButton, Qt::LeftButton); + QTRY_COMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode); + // The cached widget pointers belong to the now-hidden dialog page; null + // them so any stale dereference upstream is a crash, not a silent miss. + m_widget = nullptr; + m_applyButton = nullptr; +} + +// End-to-end Dropbox happy-path lifecycle: validation banners on empty +// state, Authorize through MockDropboxLoginFlow, Test Connection through +// MockDropboxSyncProvider::download, Apply -> autosave -> sync-on-save +// trigger -> "Remote sync 'Dropbox' completed!" + status bar timestamp, +// close/reopen DB to verify CustomData persistence, Remove + final OK +// to verify the displaced-provider single-provider invariant and that +// the post-Remove save runs WITHOUT a remote sync. +// +// Pre-arrangement: +// * MockDropboxSyncProvider is installed via initTestCase's factory +// override (lines ~70-90). +// * MockDropboxLoginFlow is injected per-Authorize-click in this test +// (NOT at fixture level -- the existing tests inject their own per +// test, and a shared fixture instance would leak state across tests). +void TestCloudSyncWidget::CloudSettingAddAndRemoveDropboxFullWorkflow() +{ + // Production default for AutoSaveAfterEveryChange is true (Config.cpp:59); + // init() overrode it to false to keep the other tests deterministic. This + // workflow specifically exercises the "Apply -> CustomData modified -> + // autosave -> databaseSaved -> onDatabaseSavedTriggerSync -> syncWithCloud" + // chain, so we restore the production default and restore False at the + // end of the test so cleanup() and any subsequent test see the same + // fixture defaults the rest of the file relies on. + config()->set(Config::AutoSaveAfterEveryChange, true); + auto restoreAutoSave = qScopeGuard([] { config()->set(Config::AutoSaveAfterEveryChange, false); }); + + // Reset mock state -- earlier tests in this binary may have already hit + // the static counters / download source / kill switch. + MockDropboxSyncProvider::resetCallCounts(); + MockDropboxSyncProvider::setDownloadSourcePath(QString()); + MockDropboxSyncProvider::setNextDownloadFailure(QString()); + MockDropboxSyncProvider::setIsAuthorizedOverride(true); + // Symmetric restore at end of test so subsequent tests in the same + // binary inherit defaults, not whatever this test last set. + auto restoreKillSwitch = + qScopeGuard([] { MockDropboxSyncProvider::setIsAuthorizedOverride(true); }); + + // The mock's download() copies its source to a temp path. We CANNOT use + // m_dbFilePath as that source: that file is the live database the test + // is operating on, and on Windows it is held open with a sharing lock + // for the whole duration the database is unlocked -- QFile::copy of a + // locked source returns false and the page would show a red error + // banner ("MockDropboxSyncProvider: failed to copy canned source"). + // The canonical read-only test data file is held by nobody, so + // QFile::copy of it always succeeds across all 4 platforms. We use it + // ONLY for Test Connection (which just QFile::remove's the result -- no + // open, no parse). For SyncEngine-driven syncs we set source="" to take + // the first-sync branch (SyncEngine.cpp:154), avoiding the merge step + // entirely. + const QString canonicalKdbxPath = QDir(KEEPASSX_TEST_DATA_DIR).absoluteFilePath(QStringLiteral("NewDatabase.kdbx")); + QVERIFY(QFileInfo::exists(canonicalKdbxPath)); + + auto* banner = m_widget->findChild(QStringLiteral("messageWidget")); + auto* comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + auto* dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + auto* appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + auto* remotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + auto* authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + auto* testConnectionButton = findInDropboxPage(m_widget, "testConnectionButton"); + auto* removeButton = findInDropboxPage(m_widget, "removeButton"); + auto* authStatusLabel = findInDropboxPage(m_widget, "authStatusLabel"); + auto* syncOnSaveCheckBox = findInDropboxPage(m_widget, "syncOnSaveCheckBox"); + auto* syncOnOpenCheckBox = findInDropboxPage(m_widget, "syncOnOpenCheckBox"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(appKeyEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + QVERIFY(syncOnSaveCheckBox); + QVERIFY(syncOnOpenCheckBox); + + auto* statusBarLabel = m_mainWindow->findChild(QStringLiteral("statusBarLabel")); + QVERIFY(statusBarLabel); + + // ---- Step 0: cloud-sync settings opened on Dropbox ------------------- + // openCloudSyncSettings() in init() already navigated us here. Confirm + // the default landing state matches a fresh database with no provider. + QCOMPARE(comboBox->currentIndex(), 0); + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + // CRITICAL: dropboxPage must actually be the visible page in the + // QStackedWidget, not just constructed under it. A regression that leaves + // the page hidden (wrong initial setCurrentIndex, unparented widget) + // would fail here. + QVERIFY(dropboxPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + + // ---- Step 1: Authorize with no app key -> warning banner ------------- + QTest::mouseClick(authorizeButton, Qt::LeftButton); + // CRITICAL: the production guard at DropboxCloudSyncPage::onAuthorizeClicked + // (cpp:228) rejects an empty app key BEFORE constructing the login flow. + // We assert on the banner the user actually sees, not on m_authState -- + // the banner is the regression surface a user can perceive. + QTRY_VERIFY(banner->isVisible()); + QCOMPARE(banner->text(), QStringLiteral("App Key is required for authorization.")); + QCOMPARE(banner->messageType(), KMessageWidget::Warning); + // No login flow should have been constructed at this point. + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + + // ---- Step 2: Test Connection with no token -> warning banner --------- + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + // CRITICAL: onTestConnectionClicked's pre-flight at cpp:296 rejects an + // unauthorized config BEFORE calling provider->download(). If a + // regression dropped that early-return, the mock provider's download + // counter below would increment. + QTRY_COMPARE(banner->text(), QStringLiteral("Authorize first before testing the connection.")); + QCOMPARE(banner->messageType(), KMessageWidget::Warning); + QCOMPARE(MockDropboxSyncProvider::downloadCallCount(), 0); + + // ---- Step 3: syncOnSave / syncOnOpen both default-checked ------------ + // CRITICAL: these are the defaults the Apply step (and the post-reopen + // sync-on-unlock step) rely on. If a regression flips either default, + // the autosave-driven sync below would not fire and this test would + // start failing at the QSignalSpy.wait(). + QVERIFY(syncOnSaveCheckBox->isChecked()); + QVERIFY(syncOnOpenCheckBox->isChecked()); + + // ---- Step 4: fill fields + Authorize -> success banner + Authorized - + QTest::keyClicks(appKeyEdit, QStringLiteral("test-app-key")); + QTest::keyClicks(remotePathEdit, QStringLiteral("/test/path.kdbx")); + + // Inject the login-flow mock BEFORE clicking Authorize so the page's + // lazy-construct in ensureLoginFlow is a no-op. setLoginFlowForTest + // reparents the flow to dropboxPage. + auto* loginFlow = new MockDropboxLoginFlow(); + loginFlow->setCannedTokens(QStringLiteral("tok-123"), QStringLiteral("rtok-456"), 99999999999LL); + loginFlow->setNextStartOutcome(MockDropboxLoginFlow::StartOutcome::Completed); + dropboxPage->setLoginFlowForTest(loginFlow); + + // Configure the mock provider so download() returns a copy of the + // canonical (never-opened) kdbx for the upcoming Test Connection click. + // SyncEngine also calls download() during the post-Apply sync; for that + // flow we want filePath="" (= remote not found) so doSave runs without + // trying to merge. We toggle that by clearing the source between the + // two flows. + MockDropboxSyncProvider::setDownloadSourcePath(canonicalKdbxPath); + + QVERIFY(authorizeButton->isEnabled()); + QTest::mouseClick(authorizeButton, Qt::LeftButton); + // CRITICAL: the visible status label and banner are what the user reads + // post-Authorize. authStatusLabel is updated by updateAuthStatus(Authorized); + // banner is fired by onAuthorizationCompleted. Both must match exactly + // or the user sees an inconsistent state. + QTRY_COMPARE(authStatusLabel->text(), QStringLiteral("Authorized")); + QCOMPARE(banner->text(), QStringLiteral("Authorization successful, click Apply to save.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + // Authorize -> mergeAndPersistTokens marks the page modified, which + // bubbles to Apply via settingsModified -> setModified(true). + QTRY_VERIFY(m_applyButton->isEnabled()); + + // ---- Step 5: Test Connection -> "Remote file found." ----------------- + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + // CRITICAL: the green banner is the user-visible proof that download() + // returned success AND a non-empty filePath. If the page ever stops + // distinguishing "found" from "will-be-created" (cpp:357-364), this + // assertion catches it. + QTRY_COMPARE(banner->text(), QStringLiteral("Connected. Remote file found.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(MockDropboxSyncProvider::downloadCallCount(), 1); + + // ---- Step 6: Apply + OK -> autosave -> sync-on-save -> "completed!" -- + // Switch the mock to first-sync mode for the SyncEngine pipeline that + // will fire from the autosave's databaseSaved signal. With filePath="" + // SyncEngine takes the doSave-only branch (cpp:154 in SyncEngine), which + // matches a "remote file doesn't exist yet" first sync and avoids + // re-running the merge against m_dbFilePath which the local save just + // overwrote. + MockDropboxSyncProvider::setDownloadSourcePath(QString()); + + QSignalSpy syncCompletedSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + QSignalSpy databaseSavedSpy(m_db.data(), &Database::databaseSaved); + // Snapshot the mock's call counters BEFORE Apply so the post-Apply + // assertions are deltas, not absolutes. Test Connection (step 5) + // already called refreshAuth + download once each -- testing in + // absolutes would falsely require us to know prior history. + const int refreshBeforeApply = MockDropboxSyncProvider::refreshAuthCallCount(); + const int downloadBeforeApply = MockDropboxSyncProvider::downloadCallCount(); + const int uploadBeforeApply = MockDropboxSyncProvider::uploadCallCount(); + + QVERIFY(m_applyButton->isEnabled()); + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + // CRITICAL: Apply must re-gray the button synchronously (TestGui:637 pattern). + QVERIFY(!m_applyButton->isEnabled()); + + // Click OK immediately -- if we wait for sync to complete here, the OK + // click's saveAllSettings (which the General page always re-stamps via + // setSettingsChanged) would fire a SECOND sync after the first finishes. + // Keeping the two clicks back-to-back means both markAsModified calls + // collapse into the same already-running 150ms timer (Database.cpp:1074 + // refuses to restart an active timer), so the autosave + sync fires + // exactly once. Matches a user who hits Apply then OK in rapid succession. + closeDatabaseSettingsViaOk(); + + // Apply + OK -> saveAllSettings (twice) -> RemoteSettings::saveSettings + // writes to m_db CustomData -> Database::markAsModified (150ms timer + // collapsed) -> onDatabaseModified -> autosave (AutoSaveAfterEveryChange + // is on) -> performSave -> databaseSaved -> onDatabaseSavedTriggerSync + // -> syncWithCloud -> SyncEngine pipeline -> databaseSyncCompleted. + // The chain is several event-loop turns long, so we wait on the + // terminal signal rather than poll an intermediate state. + QVERIFY(syncCompletedSpy.wait(5000)); + // ENGAGE the kill switch IMMEDIATELY -- no event loop iteration + // between wait() returning and this line, so the queued + // onDatabaseSavedTriggerSync (sync N's own save -> next sync trigger, + // see Database.cpp:316 RandomSlug-on-every-save) is still pending in + // the queue. By engaging the kill switch before any subsequent + // QTRY_*/wait spins the loop, the queued slot's + // isCloudSyncAuthorized() check returns false (mock isAuthorized + // returns false), so no sync 2 starts. This pins the count assertions + // below to "exactly one sync" -- the user-observable contract of + // "Apply triggers a sync." + MockDropboxSyncProvider::setIsAuthorizedOverride(false); + // CRITICAL: exactly one sync fired with displayName "Dropbox". One + // sync, not zero (regression: Apply doesn't trigger sync-on-save) and + // not two+ (regression: Apply emits cloudSyncTriggered AND triggers + // the autosave chain, double-firing). + QCOMPARE(syncCompletedSpy.count(), 1); + QCOMPARE(syncCompletedSpy.at(0).at(0).toString(), QStringLiteral("Dropbox")); + // databaseSaved fires twice in this step: once from the autosave that + // runs after Apply+OK's CustomData mod (the trigger for sync 1), and + // once from sync 1's own doSave (SyncEngine.cpp:218 m_saveFn -> + // performSave -> m_db->save -> markAsClean -> emit databaseSaved). + QCOMPARE(databaseSavedSpy.count(), 2); + // SyncEngine's pipeline calls each of refreshAuth/download/upload + // exactly once per sync (SyncEngine.cpp doAuthenticate/doDownload/ + // doUpload). One sync -> one of each. + QCOMPARE(MockDropboxSyncProvider::refreshAuthCallCount() - refreshBeforeApply, 1); + QCOMPARE(MockDropboxSyncProvider::downloadCallCount() - downloadBeforeApply, 1); + QCOMPARE(MockDropboxSyncProvider::uploadCallCount() - uploadBeforeApply, 1); + + // ---- Step 7: post-OK -> banner + status bar on main view ------------- + // CRITICAL: the "Remote sync 'X' completed!" banner is set by + // DatabaseWidget::showMessage from inside the SyncEngine::syncFinished + // lambda (DatabaseWidget.cpp:1425) -- the user sees it on the main + // database view, not inside the now-closed settings dialog. The + // "databaseWidgetMessageWidget" objectName is the testability hook we + // added on m_messageWidget; without it, findChild would + // return some unrelated descendant (Edit pages each carry their own + // unnamed messageWidget) and pick the wrong one. + auto* mainMessage = m_dbWidget->findChild(QStringLiteral("databaseWidgetMessageWidget")); + QVERIFY(mainMessage); + QTRY_VERIFY(mainMessage->text().contains(QStringLiteral("Remote sync 'Dropbox' completed!"))); + QCOMPARE(mainMessage->messageType(), KMessageWidget::Positive); + // The status bar caption is set by MainWindow::updateSyncStatusBar + // (cpp:1695-1707) via the databaseSyncCompleted slot. Format is + // ": Synced h:mm AP" (MainWindow.cpp:1697). We assert the + // brand prefix and the structural "h:mm AM" / "h:mm PM" tail rather + // than a literal clock time, which would race the wall clock. + QTRY_VERIFY(statusBarLabel->text().startsWith(QStringLiteral("Dropbox: Synced "))); + // QRegularExpression (not QRegExp -- the latter is deprecated in Qt 5.15 + // and removed in Qt 6). The anchors ^ / $ make it an exact match. + QRegularExpression clockRegex(QStringLiteral("^Dropbox: Synced \\d{1,2}:\\d{2} (AM|PM)$")); + QVERIFY2(clockRegex.match(statusBarLabel->text()).hasMatch(), + qPrintable(QString("statusBarLabel text doesn't match 'Dropbox: Synced h:mm AM/PM' shape: %1") + .arg(statusBarLabel->text()))); + + // ---- Step 8: reopen Cloud Sync -> Dropbox + Test Connection green --- + // Going back through the menu action exercises the same code path as a + // user clicking Database > Settings -> Cloud Sync, including initialize() + // re-reading the on-disk RemoteSettings from m_db's CustomData. + openCloudSyncSettings(); + // Need to re-acquire widget pointers under the now-rebuilt page tree. + banner = m_widget->findChild(QStringLiteral("messageWidget")); + comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + remotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + testConnectionButton = findInDropboxPage(m_widget, "testConnectionButton"); + removeButton = findInDropboxPage(m_widget, "removeButton"); + authStatusLabel = findInDropboxPage(m_widget, "authStatusLabel"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(appKeyEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + QVERIFY(dropboxPage->isVisible()); + // CRITICAL: after a save+reload-from-CustomData round trip, the page must + // show the persisted state (not an empty form). If loadFromConfig stops + // populating any of these fields, every downstream test would still pass + // against an empty UI -- the user-visible state would be wrong though. + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized")); + QCOMPARE(appKeyEdit->text(), QStringLiteral("test-app-key")); + QCOMPARE(remotePathEdit->text(), QStringLiteral("/test/path.kdbx")); + // Switch the mock back to "remote file found" so Test Connection's + // download returns a non-empty filePath again. Canonical-kdbx, not + // m_dbFilePath -- m_dbFilePath is held open and unfit as a copy source. + MockDropboxSyncProvider::setDownloadSourcePath(canonicalKdbxPath); + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + QTRY_COMPARE(banner->text(), QStringLiteral("Connected. Remote file found.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized")); + + // Capture the persisted JSON for the post-reopen equality check below. + QJsonObject dropboxConfigBeforeClose; + { + RemoteSettings rs(m_db, nullptr); + dropboxConfigBeforeClose = + rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")); + QCOMPARE(dropboxConfigBeforeClose[QStringLiteral("type")].toString(), QStringLiteral("dropbox")); + QCOMPARE(dropboxConfigBeforeClose[QStringLiteral("appKey")].toString(), QStringLiteral("test-app-key")); + QCOMPARE(dropboxConfigBeforeClose[QStringLiteral("remotePath")].toString(), QStringLiteral("/test/path.kdbx")); + QCOMPARE(dropboxConfigBeforeClose[QStringLiteral("accessToken")].toString(), QStringLiteral("tok-123")); + QCOMPARE(dropboxConfigBeforeClose[QStringLiteral("refreshToken")].toString(), QStringLiteral("rtok-456")); + QCOMPARE(rs.activeProvider(), QStringLiteral("dropbox")); + } + + // ---- Step 9: close + reopen db, JSON survives unchanged -------------- + // Close the settings dialog via Cancel so we land back on the main view + // (closing via OK would re-trigger a save+sync round we've already + // verified). triggerAction("actionDatabaseClose") then closes the db. + { + auto* dialog = m_dbWidget->findChild("databaseSettingsDialog"); + QVERIFY(dialog); + auto* buttonBox = dialog->findChild(); + QVERIFY(buttonBox); + auto* cancelButton = buttonBox->button(QDialogButtonBox::Cancel); + QVERIFY(cancelButton); + QTest::mouseClick(cancelButton, Qt::LeftButton); + QTRY_COMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode); + m_widget = nullptr; + m_applyButton = nullptr; + } + + // Reset spy on the OLD m_dbWidget before close, then replace. + { + // DO NOT autosave during close -- the cloud-sync state is already + // persisted, and an extra save would trigger another sync we don't + // want to wait on. + // + // Tradeoff acknowledged: markAsClean here would mask a regression + // that legitimately left the CustomData write from step 6 unsaved. + // The downstream step-9 JSON check (constructing a fresh + // RemoteSettings from the reopened m_db and asserting it matches + // the pre-close snapshot) catches that case indirectly -- if the + // step-6 write hadn't been autosaved to disk, the reopened db + // would lack the provider and the JSON-equality assertion would + // fail. + m_db->markAsClean(); + MessageBox::setNextAnswer(MessageBox::No); + triggerAction("actionDatabaseClose"); + QApplication::processEvents(); + MessageBox::setNextAnswer(MessageBox::NoButton); + delete m_dbWidget; + m_db.reset(); + } + + // Flip the mock back to first-sync mode BEFORE the reopen. Once the + // database is reopened it grabs a Windows sharing lock on m_dbFilePath, + // and the sync-on-open trigger that fires inside SyncEngine would call + // download() which would try to copy the still-locked m_dbFilePath if + // the source were stale from step 8 above -- failing the sync with a + // red "failed to copy canned source" banner. Empty source means + // SyncEngine takes the "remote file not found / first sync" branch + // (SyncEngine.cpp:154) and never touches the filesystem source. + MockDropboxSyncProvider::setDownloadSourcePath(QString()); + // RE-ARM the kill switch: we want the legitimate sync-on-open to fire + // when the reopened db unlocks. We'll engage the kill switch again + // right after we verify the reopen sync completed. + MockDropboxSyncProvider::setIsAuthorizedOverride(true); + + // Reopen. onDatabaseUnlockedTriggerSync will fire syncOnOpen -- we have + // to wait for it BEFORE we touch the JSON, otherwise the sync's local + // save races our assertions. Manual inline reopen (rather than the + // reopenDatabaseAfterClose helper) so the QSignalSpy lands BEFORE the + // password Enter -- the mock provider is fast enough that the sync can + // complete between Enter-keypress and a spy created post-helper, making + // a wait() call on that spy time out forever. + { + m_mainWindow->activateWindow(); + QApplication::processEvents(); + fileDialog()->setNextFileName(m_dbFilePath); + triggerAction("actionDatabaseOpen"); + QApplication::processEvents(); + + m_dbWidget = m_tabWidget->currentDatabaseWidget(); + QVERIFY(m_dbWidget); + // Spy goes here -- BEFORE the password Enter that triggers + // databaseUnlocked -> QTimer::singleShot(0, syncWithCloud). + QSignalSpy reopenSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + + auto* databaseOpenWidget = m_dbWidget->findChild("databaseOpenWidget"); + QVERIFY(databaseOpenWidget); + auto* editPassword = + databaseOpenWidget->findChild("editPassword")->findChild("passwordEdit"); + QVERIFY(editPassword); + editPassword->setFocus(); + QTRY_VERIFY(editPassword->hasFocus()); + QTest::keyClicks(editPassword, "a"); + QTest::keyClick(editPassword, Qt::Key_Enter); + + QTRY_VERIFY(!m_dbWidget->isLocked()); + m_db = m_dbWidget->database(); + // CRITICAL: poll for the sync-on-unlock to complete. If syncOnOpen + // is ever flipped off by default, or onDatabaseUnlockedTriggerSync + // drops its dispatch, this would time out -- pinning the contract + // that opening an authorized database triggers a sync. + if (reopenSyncSpy.count() == 0) { + QVERIFY(reopenSyncSpy.wait(5000)); + } + // ENGAGE the kill switch IMMEDIATELY (same rationale as after + // step 6's wait): no event loop iteration between wait() returning + // and here, so the queued slot from sync-on-open's own doSave is + // still pending in the queue. Engage now -> sync 2 won't start. + MockDropboxSyncProvider::setIsAuthorizedOverride(false); + // CRITICAL: exactly one sync. The regression we're catching is + // "reopen triggers zero syncs" (broken syncOnOpen wiring) and + // "reopen triggers more than one" (the chain leaking past the + // kill switch -> means the kill switch broke). + QCOMPARE(reopenSyncSpy.count(), 1); + QCOMPARE(reopenSyncSpy.at(0).at(0).toString(), QStringLiteral("Dropbox")); + } + + // CRITICAL: the persisted JSON read from the freshly-opened db must + // match what was on disk before close. A regression that drops fields + // during save/load round-trip (e.g. tokens not written, refreshToken + // missing) would fail here. + { + RemoteSettings rs(m_db, nullptr); + QJsonObject after = + rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")); + QCOMPARE(after[QStringLiteral("type")].toString(), dropboxConfigBeforeClose[QStringLiteral("type")].toString()); + QCOMPARE(after[QStringLiteral("appKey")].toString(), + dropboxConfigBeforeClose[QStringLiteral("appKey")].toString()); + QCOMPARE(after[QStringLiteral("remotePath")].toString(), + dropboxConfigBeforeClose[QStringLiteral("remotePath")].toString()); + QCOMPARE(after[QStringLiteral("accessToken")].toString(), + dropboxConfigBeforeClose[QStringLiteral("accessToken")].toString()); + QCOMPARE(after[QStringLiteral("refreshToken")].toString(), + dropboxConfigBeforeClose[QStringLiteral("refreshToken")].toString()); + QCOMPARE(rs.activeProvider(), QStringLiteral("dropbox")); + } + + // ---- Step 10: reopen Cloud Sync after db reopen ---------------------- + openCloudSyncSettings(); + banner = m_widget->findChild(QStringLiteral("messageWidget")); + comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + remotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + authorizeButton = findInDropboxPage(m_widget, "authorizeButton"); + testConnectionButton = findInDropboxPage(m_widget, "testConnectionButton"); + removeButton = findInDropboxPage(m_widget, "removeButton"); + authStatusLabel = findInDropboxPage(m_widget, "authStatusLabel"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(appKeyEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + + // CRITICAL: combobox text + index together, not just one. A regression + // that desynchronizes provider-stacked-widget index from the combobox + // selection (initialize() at DatabaseSettingsWidgetCloudSync.cpp:135-144) + // could leave the combobox showing "Dropbox" while the displayed page is + // Nextcloud, or vice versa. + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + QCOMPARE(comboBox->currentIndex(), 0); + QVERIFY(dropboxPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized")); + + // Canonical-kdbx source for Test Connection (m_dbFilePath is locked open). + MockDropboxSyncProvider::setDownloadSourcePath(canonicalKdbxPath); + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + QTRY_COMPARE(banner->text(), QStringLiteral("Connected. Remote file found.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized")); + + // ---- Step 11: Remove -> banner + UI cleared + JSON gone -------------- + QCOMPARE(MockDropboxSyncProvider::revokeTokenCallCount(), 0); + QTest::mouseClick(removeButton, Qt::LeftButton); + // CRITICAL: the green confirmation banner is the post-Remove user-facing + // proof. The exact text comes from DropboxCloudSyncPage::onRemoveClicked + // (cpp:431) -- a regression that drops the emit would leave the user + // wondering whether Remove worked at all. + QTRY_COMPARE(banner->text(), QStringLiteral("Cloud sync configuration removed.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + // The auth status label must flip back to "Not authorized" -- the + // reverse of step 4's transition. + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + // The UI fields must be empty (cleared under QSignalBlockers in + // onRemoveClicked cpp:421-425). + QVERIFY(appKeyEdit->text().isEmpty()); + QVERIFY(remotePathEdit->text().isEmpty()); + // CRITICAL: Apply must be grayed after Remove. m_modified is reset to + // false in onRemoveClicked (cpp:435), and the dialog's overall modified + // flag is driven by the page's modified() signal -- if the Remove path + // accidentally re-emits modified() (e.g. via the cleared() chain), this + // assertion catches the leak. Apply re-enable here would be especially + // bad: a subsequent Apply would have nothing to save (saveToConfig + // returns empty for fresh-no-edit) but would still re-stamp other + // widgets' state. + QVERIFY(!m_applyButton->isEnabled()); + // The displaced revokeToken was called once -- the page's onRemoveClicked + // dispatches revoke through m_dropboxProvider for the still-authorized + // path (cpp:393-403). + QCOMPARE(MockDropboxSyncProvider::revokeTokenCallCount(), 1); + // JSON must be gone from CustomData -- onRemoveClicked persisted the + // removal via m_remoteSettings->removeProviderConfig + saveSettings. + { + RemoteSettings rs(m_db, nullptr); + QVERIFY(rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")).isEmpty()); + } + + // ---- Step 12: OK -> save fires, NO remote sync triggered ------------ + // After Remove the persisted CustomData no longer has a Dropbox entry, + // and the kill switch is engaged so SyncEngine's isAuthorized check + // returns false. The OK click triggers saveAllSettings -> the General + // page re-stamps SettingsChanged -> Database modified -> autosave + // (because AutoSaveAfterEveryChange is on) -> Database::databaseSaved. + // The queued onDatabaseSavedTriggerSync runs but isCloudSyncAuthorized + // returns false (kill switch -> mock isAuthorized returns false), so + // no sync starts. + QSignalSpy postRemoveSavedSpy(m_db.data(), &Database::databaseSaved); + QSignalSpy postRemoveSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + closeDatabaseSettingsViaOk(); + // CRITICAL: the save itself must run -- this confirms the cleared + // CustomData entry actually lands on disk, not just in memory. A + // regression that drops the autosave (e.g. an over-eager + // m_blockAutoSave) would silently leave the dropbox CustomData entry + // on disk and the next open would re-resurrect the provider. + QVERIFY(postRemoveSavedSpy.wait(5000)); + QVERIFY(postRemoveSavedSpy.count() >= 1); + // CRITICAL: zero syncs after Remove + OK. The kill switch ensures the + // chain breaker takes effect at the SyncEngine entry point, so this + // assertion is meaningful: it locks in that OK after Remove must not + // construct a sync that PROCEEDS past isAuthorized. A regression that + // makes the queued slot bypass its isCloudSyncAuthorized check, or + // adds a NEW sync trigger that bypasses isAuthorized, would tick this + // spy and trip the assertion. Drain pending events for a beat so any + // queued slot has a chance to run before we check. + QTest::qWait(200); + QCOMPARE(postRemoveSyncSpy.count(), 0); + // CRITICAL: the on-disk JSON has no dropbox entry. A regression that + // somehow re-adds the provider during OK's saveAllSettings (e.g. the + // cloud-sync widget's saveSettings calling setProviderConfig on an + // empty config instead of skipping) would re-resurrect dropbox here. + { + RemoteSettings rs(m_db, nullptr); + QVERIFY(rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")).isEmpty()); + QVERIFY(rs.activeProvider().isEmpty()); + } + + // ---- Step 13: reopen Cloud Sync -> still empty, JSON still empty ----- + openCloudSyncSettings(); + comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + dropboxPage = m_widget->findChild(QStringLiteral("dropboxPage")); + appKeyEdit = findInDropboxPage(m_widget, "appKeyEdit"); + remotePathEdit = findInDropboxPage(m_widget, "remotePathEdit"); + authStatusLabel = findInDropboxPage(m_widget, "authStatusLabel"); + QVERIFY(comboBox); + QVERIFY(dropboxPage); + QVERIFY(appKeyEdit); + QVERIFY(remotePathEdit); + QVERIFY(authStatusLabel); + + // CRITICAL: a regression that re-persists an empty/zombie provider + // entry on save would show "Dropbox / Not authorized / empty fields" + // here -- visually the same as the truly-removed state but with a + // CustomData record still present. The JSON check below is what + // distinguishes those two outcomes. + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + QVERIFY(dropboxPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + QVERIFY(appKeyEdit->text().isEmpty()); + QVERIFY(remotePathEdit->text().isEmpty()); + { + RemoteSettings rs(m_db, nullptr); + QVERIFY(rs.getProviderConfig(QStringLiteral("dropbox"), QStringLiteral("dropbox-default")).isEmpty()); + } + + // Reset the mock so it doesn't bleed into other tests in this binary. + MockDropboxSyncProvider::setDownloadSourcePath(QString()); +} + +// End-to-end Nextcloud happy-path lifecycle, structurally parallel to +// CloudSettingAddAndRemoveDropboxFullWorkflow: validation banners on empty +// state, Authorize through MockNextcloudLoginFlow, Test Connection through +// MockNextcloudSyncProvider::testConnection, Apply -> autosave -> sync-on-save +// trigger -> "Remote sync 'Nextcloud' completed!" + status bar timestamp, +// close/reopen DB to verify CustomData persistence, Remove + final OK to +// verify the post-Remove save runs WITHOUT a remote sync. +// +// One block intentionally fails: after closing and reopening the dialog, +// loadFromConfig auto-checks the appPasswordGroupBox when both loginName and +// appPassword are persisted. The test asserts the box is STILL unchecked at +// that point per the user's intended UX (the box should only auto-open when +// the user explicitly chose the paste-creds path). Locking that contract via a +// failing test now means the eventual fix flips this test green without +// needing to add new assertions. +// +// Pre-arrangement: +// * MockNextcloudSyncProvider is installed via initTestCase's factory +// override (lines ~70-95). +// * MockNextcloudLoginFlow is injected per-Authorize-click via the test +// seam NextcloudCloudSyncPage::setLoginFlowForTest (mirrors Dropbox). +void TestCloudSyncWidget::CloudSettingAddAndRemoveNextCloudFullWorkflow() +{ + // Same AutoSaveAfterEveryChange dance as the Dropbox workflow: the test + // exercises the "Apply -> CustomData modified -> autosave -> databaseSaved + // -> onDatabaseSavedTriggerSync -> syncWithCloud" chain, so we restore + // the production default for the duration of this test and put it back + // at end so subsequent tests inherit the fixture's deterministic False. + config()->set(Config::AutoSaveAfterEveryChange, true); + auto restoreAutoSave = qScopeGuard([] { config()->set(Config::AutoSaveAfterEveryChange, false); }); + + // Reset mock state -- earlier tests in this binary may have already hit + // the static counters / download source / kill switch. + MockNextcloudSyncProvider::resetCallCounts(); + MockNextcloudSyncProvider::setDownloadSourcePath(QString()); + MockNextcloudSyncProvider::setNextDownloadFailure(QString()); + MockNextcloudSyncProvider::setIsAuthorizedOverride(true); + auto restoreKillSwitch = + qScopeGuard([] { MockNextcloudSyncProvider::setIsAuthorizedOverride(true); }); + + // Canonical (read-only, never-opened) kdbx serves as the source for Test + // Connection clicks that should report "Nextcloud connection successful." + // (= file found). We cannot use m_dbFilePath as source while the database + // is unlocked: on Windows it's held with a sharing lock and QFile::copy + // would fail. For SyncEngine-driven syncs we set source="" so the + // first-sync branch (SyncEngine.cpp:154) runs and avoids re-merging the + // canonical kdbx every iteration (same chain-breaker rationale as the + // Dropbox workflow). + const QString canonicalKdbxPath = QDir(KEEPASSX_TEST_DATA_DIR).absoluteFilePath(QStringLiteral("NewDatabase.kdbx")); + QVERIFY(QFileInfo::exists(canonicalKdbxPath)); + + auto* banner = m_widget->findChild(QStringLiteral("messageWidget")); + auto* comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + auto* nextcloudPage = m_widget->findChild(QStringLiteral("nextcloudPage")); + auto* serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + auto* remotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + auto* authorizeButton = findInNextcloudPage(m_widget, "authorizeButton"); + auto* testConnectionButton = findInNextcloudPage(m_widget, "testConnectionButton"); + auto* removeButton = findInNextcloudPage(m_widget, "removeButton"); + auto* authStatusLabel = findInNextcloudPage(m_widget, "authStatusLabel"); + auto* appPasswordGroupBox = findInNextcloudPage(m_widget, "appPasswordGroupBox"); + auto* loginNameEdit = findInNextcloudPage(m_widget, "loginNameEdit"); + auto* appPasswordEdit = findInNextcloudPage(m_widget, "appPasswordEdit"); + auto* syncOnSaveCheckBox = findInNextcloudPage(m_widget, "syncOnSaveCheckBox"); + auto* syncOnOpenCheckBox = findInNextcloudPage(m_widget, "syncOnOpenCheckBox"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(nextcloudPage); + QVERIFY(serverBaseUrlEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + QVERIFY(appPasswordGroupBox); + QVERIFY(loginNameEdit); + QVERIFY(appPasswordEdit); + QVERIFY(syncOnSaveCheckBox); + QVERIFY(syncOnOpenCheckBox); + + auto* statusBarLabel = m_mainWindow->findChild(QStringLiteral("statusBarLabel")); + QVERIFY(statusBarLabel); + + // ---- Step 0: cloud-sync settings opened on Dropbox (fresh DB) -------- + // openCloudSyncSettings() in init() lands on the Dropbox page since the + // database has no cloud-sync provider configured yet. Confirm before + // switching to Nextcloud so a regression that changes the default landing + // page would fail here, not on a confusing downstream assertion. + QCOMPARE(comboBox->currentIndex(), 0); + QCOMPARE(comboBox->currentText(), QStringLiteral("Dropbox")); + + // ---- Step 1: switch combobox to Nextcloud ---------------------------- + comboBox->setCurrentIndex(1); + QCOMPARE(comboBox->currentText(), QStringLiteral("Nextcloud")); + // CRITICAL: the providerStackedWidget must follow the combobox -- a + // desync (one shows Nextcloud, the other still Dropbox) would let every + // downstream assertion run against the wrong page widgets and silently + // miss real regressions. + QTRY_VERIFY(nextcloudPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + // Switching providers is not a user edit -- Apply stays grayed. + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 2: Authorize with empty fields -> warning banner ----------- + QTest::mouseClick(authorizeButton, Qt::LeftButton); + // CRITICAL: validateAndCanonicalizeServerUrl's Empty branch is the + // production guard at NextcloudCloudSyncPage::onAuthorizeClicked. The + // banner is the user-visible regression surface; assert on it, not on + // m_authState. + QTRY_VERIFY(banner->isVisible()); + QCOMPARE(banner->text(), QStringLiteral("Enter the Nextcloud server URL first.")); + QCOMPARE(banner->messageType(), KMessageWidget::Warning); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + + // ---- Step 3: Test Connection with no auth -> warning banner ---------- + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + // CRITICAL: onTestConnectionClicked's pre-flight rejects an unauthorized + // config BEFORE calling provider->testConnection. If a regression dropped + // that early-return, the mock provider's testConnection counter below + // would increment. + QTRY_COMPARE(banner->text(), QStringLiteral("Authorize Nextcloud first to test the connection.")); + QCOMPARE(banner->messageType(), KMessageWidget::Warning); + QCOMPARE(MockNextcloudSyncProvider::testConnectionCallCount(), 0); + + // ---- Step 4: syncOnSave / syncOnOpen both default-checked ------------ + // CRITICAL: these are the defaults the Apply step (and the post-reopen + // sync-on-unlock step) rely on. If a regression flips either default, + // the autosave-driven sync below would not fire and this test would + // start failing at the QSignalSpy.wait(). + QVERIFY(syncOnSaveCheckBox->isChecked()); + QVERIFY(syncOnOpenCheckBox->isChecked()); + + // ---- Step 5: fill fields + Authorize -> success banner + Authorized -- + QTest::keyClicks(serverBaseUrlEdit, QStringLiteral("https://cloud.example.com")); + QTRY_VERIFY(m_applyButton->isEnabled()); + QTest::keyClicks(remotePathEdit, QStringLiteral("/Passwords/Database.kdbx")); + + // Inject the login-flow mock BEFORE clicking Authorize so the page's + // lazy-construct in onAuthorizeClicked is a no-op (setLoginFlowForTest + // wins). setLoginFlowForTest reparents the flow to nextcloudPage. + auto* loginFlow = new MockNextcloudLoginFlow(); + loginFlow->setCannedCreds(QStringLiteral("test-login-alice"), QStringLiteral("canned-app-pw-456")); + loginFlow->setNextStartOutcome(MockNextcloudLoginFlow::StartOutcome::Completed); + nextcloudPage->setLoginFlowForTest(loginFlow); + + QVERIFY(authorizeButton->isEnabled()); + QTest::mouseClick(authorizeButton, Qt::LeftButton); + // CRITICAL: visible status + banner are what the user reads post-Authorize. + // authStatusLabel is rendered by updateAuthStatus(Authorized) as + // "Authorized as "; banner is emitted by onLoginCompleted. + QTRY_VERIFY(authStatusLabel->text().startsWith(QStringLiteral("Authorized"))); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + QCOMPARE(banner->text(), QStringLiteral("Authorization successful, click Apply to save.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + // Authorize -> onLoginCompleted -> emit modified() -> Apply enabled. + QTRY_VERIFY(m_applyButton->isEnabled()); + + // ---- Step 6: Test Connection -> "File not found ..." first-sync ------ + // Source path NOT set on the mock -> testConnection returns success with + // empty filePath -> page distinguishes that as the file-not-found branch + // and emits the "will be created on first sync" banner. Snapshot the + // counter BEFORE the click so the delta-of-1 assertion is robust to + // earlier tests in the binary having driven the counter. + const int testConnBeforeFirst = MockNextcloudSyncProvider::testConnectionCallCount(); + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + // CRITICAL: the green banner is the user-visible proof the page took the + // empty-filePath branch. If the page ever stops distinguishing "found" + // from "will-be-created", this assertion catches it. + QTRY_COMPARE(banner->text(), + QStringLiteral("Connected. File not found -- it will be created on first sync.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(MockNextcloudSyncProvider::testConnectionCallCount() - testConnBeforeFirst, 1); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + + // ---- Step 7: appPasswordGroupBox unchecked + fields filled & grayed -- + // CRITICAL: onLoginCompleted persists loginName/appPassword into the line + // edits but does NOT call appPasswordGroupBox->setChecked(true). User + // mental model for the Login Flow v2 path: the box stays "closed" -- the + // user authorized via the browser handshake, not via paste. The fields + // are populated but visually grayed (QGroupBox checkable + unchecked -> + // children disabled). A regression that auto-checks the box here would + // confuse the Login-Flow-v2 user into thinking they took the paste path. + QVERIFY(!appPasswordGroupBox->isChecked()); + QCOMPARE(loginNameEdit->text(), QStringLiteral("test-login-alice")); + QCOMPARE(appPasswordEdit->text(), QStringLiteral("canned-app-pw-456")); + // QGroupBox in checkable+unchecked mode disables its children via + // _q_setChildrenEnabled -- isEnabled() returns false. This is the + // user-visible "grayed" property. + QVERIFY(!loginNameEdit->isEnabled()); + QVERIFY(!appPasswordEdit->isEnabled()); + + // ---- Step 8: Apply -> autosave -> sync-on-save -> "completed!" ------- + // Source stays empty so SyncEngine takes the first-sync branch + // (SyncEngine.cpp:154) and skips merge against m_dbFilePath -- same + // chain-breaker rationale as the Dropbox workflow. + MockNextcloudSyncProvider::setDownloadSourcePath(QString()); + + QSignalSpy syncCompletedSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + QSignalSpy databaseSavedSpy(m_db.data(), &Database::databaseSaved); + const int refreshBeforeApply = MockNextcloudSyncProvider::refreshAuthCallCount(); + const int downloadBeforeApply = MockNextcloudSyncProvider::downloadCallCount(); + const int uploadBeforeApply = MockNextcloudSyncProvider::uploadCallCount(); + + QVERIFY(m_applyButton->isEnabled()); + QVERIFY(m_applyButton->isVisible()); + QTest::mouseClick(m_applyButton, Qt::LeftButton); + // CRITICAL: Apply re-grays the button synchronously (TestGui:637 pattern). + QVERIFY(!m_applyButton->isEnabled()); + + // ---- Step 9: state assertions BETWEEN Apply and OK ------------------- + // CRITICAL: Apply does not call loadFromConfig and must NOT touch the + // groupBox check state. The user's contract is "after Apply, the UI looks + // exactly like it did before Apply, just with the Apply button disabled." + // A regression that re-runs loadFromConfig in saveSettings (or one that + // calls setChecked(true) on the groupBox after a successful save) would + // flip these assertions before we ever close the dialog. + QVERIFY(!appPasswordGroupBox->isChecked()); + QCOMPARE(loginNameEdit->text(), QStringLiteral("test-login-alice")); + QCOMPARE(appPasswordEdit->text(), QStringLiteral("canned-app-pw-456")); + QVERIFY(!loginNameEdit->isEnabled()); + QVERIFY(!appPasswordEdit->isEnabled()); + + // Click OK back-to-back with Apply -- same 150ms-timer-collapse rationale + // as the Dropbox workflow: both markAsModified calls fold into one already- + // running timer, autosave + sync fire exactly once. + closeDatabaseSettingsViaOk(); + + QVERIFY(syncCompletedSpy.wait(5000)); + // Engage the kill switch IMMEDIATELY (no event-loop spin in between) so + // any queued onDatabaseSavedTriggerSync from sync 1's own doSave sees + // isAuthorized()=false and does not start sync 2. Pins the count + // assertions below to "exactly one sync." + MockNextcloudSyncProvider::setIsAuthorizedOverride(false); + // CRITICAL: exactly one sync fired with displayName "Nextcloud". Catches + // both "Apply doesn't trigger sync-on-save" (count 0) and "Apply emits + // cloudSyncTriggered AND triggers the autosave chain, double-firing" + // (count >= 2). + QCOMPARE(syncCompletedSpy.count(), 1); + QCOMPARE(syncCompletedSpy.at(0).at(0).toString(), QStringLiteral("Nextcloud")); + // databaseSaved fires twice in this step (same as Dropbox workflow): + // autosave-after-Apply, then sync 1's own doSave. + QCOMPARE(databaseSavedSpy.count(), 2); + QCOMPARE(MockNextcloudSyncProvider::refreshAuthCallCount() - refreshBeforeApply, 1); + QCOMPARE(MockNextcloudSyncProvider::downloadCallCount() - downloadBeforeApply, 1); + QCOMPARE(MockNextcloudSyncProvider::uploadCallCount() - uploadBeforeApply, 1); + + // ---- Step 10: post-OK -> banner + status bar on main view ------------ + // CRITICAL: the "Remote sync 'X' completed!" banner is set by + // DatabaseWidget::showMessage from inside SyncEngine::syncFinished's + // lambda -- the user sees it on the main database view, not in the now- + // closed settings dialog. The "databaseWidgetMessageWidget" objectName + // disambiguates from the various unnamed Edit-page messageWidgets. + auto* mainMessage = m_dbWidget->findChild(QStringLiteral("databaseWidgetMessageWidget")); + QVERIFY(mainMessage); + QTRY_VERIFY(mainMessage->text().contains(QStringLiteral("Remote sync 'Nextcloud' completed!"))); + QCOMPARE(mainMessage->messageType(), KMessageWidget::Positive); + // Status-bar caption: MainWindow::updateSyncStatusBar formats + // ": Synced h:mm AP". Assert brand prefix + structural shape + // rather than a literal clock time -- the latter would race the wall clock. + QTRY_VERIFY(statusBarLabel->text().startsWith(QStringLiteral("Nextcloud: Synced "))); + QRegularExpression clockRegex(QStringLiteral("^Nextcloud: Synced \\d{1,2}:\\d{2} (AM|PM)$")); + QVERIFY2(clockRegex.match(statusBarLabel->text()).hasMatch(), + qPrintable(QString("statusBarLabel text doesn't match 'Nextcloud: Synced h:mm AM/PM' shape: %1") + .arg(statusBarLabel->text()))); + + // ---- Step 11: reopen Cloud Sync -> Nextcloud + Test Connection green - + // Re-arm the source so the next Test Connection click reports "file found" + // (= "Nextcloud connection successful.") instead of the first-sync banner. + MockNextcloudSyncProvider::setDownloadSourcePath(canonicalKdbxPath); + + openCloudSyncSettings(); + banner = m_widget->findChild(QStringLiteral("messageWidget")); + comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + nextcloudPage = m_widget->findChild(QStringLiteral("nextcloudPage")); + serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + remotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + authorizeButton = findInNextcloudPage(m_widget, "authorizeButton"); + testConnectionButton = findInNextcloudPage(m_widget, "testConnectionButton"); + removeButton = findInNextcloudPage(m_widget, "removeButton"); + authStatusLabel = findInNextcloudPage(m_widget, "authStatusLabel"); + appPasswordGroupBox = findInNextcloudPage(m_widget, "appPasswordGroupBox"); + loginNameEdit = findInNextcloudPage(m_widget, "loginNameEdit"); + appPasswordEdit = findInNextcloudPage(m_widget, "appPasswordEdit"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(nextcloudPage); + QVERIFY(serverBaseUrlEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + QVERIFY(appPasswordGroupBox); + QVERIFY(loginNameEdit); + QVERIFY(appPasswordEdit); + + // CRITICAL: combobox text + index together. initialize()'s + // active-provider lookup must land on Nextcloud after the previous + // session persisted nextcloud-default; a desync would either show Dropbox + // (Apply didn't persist activeProvider) or show Nextcloud combobox while + // the stacked widget still points at Dropbox. + QCOMPARE(comboBox->currentText(), QStringLiteral("Nextcloud")); + QCOMPARE(comboBox->currentIndex(), 1); + QVERIFY(nextcloudPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + QTRY_COMPARE(banner->text(), QStringLiteral("Nextcloud connection successful.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + + // ---- Step 12: EXPECTED FAILURE -- groupBox auto-checked on reopen ---- + // The user-intended contract is "the groupBox reflects whether the user + // explicitly chose the paste path -- not whether creds happen to be + // persisted." NextcloudCloudSyncPage::loadFromConfig currently violates + // this by auto-checking the box whenever both loginName and appPassword + // are present (see NextcloudCloudSyncPage.cpp around line 156). + // + // Locking the intended behavior with these asserts now ensures the + // forthcoming fix flips this test green WITHOUT needing to add new + // assertions -- and a regression that re-introduces the auto-check after + // the fix would fail the same lines. The test is expected to FAIL here + // until the page-side fix lands; everything below in the same function + // will be unreached until then. + QVERIFY(!appPasswordGroupBox->isChecked()); + QVERIFY(!loginNameEdit->isEnabled()); + QVERIFY(!appPasswordEdit->isEnabled()); + + // ---- Step 13: persisted JSON contains the Nextcloud config ----------- + QJsonObject nextcloudConfigBeforeClose; + { + RemoteSettings rs(m_db, nullptr); + nextcloudConfigBeforeClose = + rs.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")); + QCOMPARE(nextcloudConfigBeforeClose[QStringLiteral("type")].toString(), QStringLiteral("nextcloud")); + QCOMPARE(nextcloudConfigBeforeClose[QStringLiteral("loginName")].toString(), + QStringLiteral("test-login-alice")); + QCOMPARE(nextcloudConfigBeforeClose[QStringLiteral("appPassword")].toString(), + QStringLiteral("canned-app-pw-456")); + QCOMPARE(nextcloudConfigBeforeClose[QStringLiteral("serverBaseUrl")].toString(), + QStringLiteral("https://cloud.example.com")); + QCOMPARE(nextcloudConfigBeforeClose[QStringLiteral("remotePath")].toString(), + QStringLiteral("/Passwords/Database.kdbx")); + QCOMPARE(rs.activeProvider(), QStringLiteral("nextcloud")); + } + + // ---- Step 14: close + reopen db, JSON survives unchanged ------------- + // Close the settings dialog via Cancel (no extra save + sync round) then + // close the database. Same dance as the Dropbox workflow. + { + auto* dialog = m_dbWidget->findChild("databaseSettingsDialog"); + QVERIFY(dialog); + auto* buttonBox = dialog->findChild(); + QVERIFY(buttonBox); + auto* cancelButton = buttonBox->button(QDialogButtonBox::Cancel); + QVERIFY(cancelButton); + QTest::mouseClick(cancelButton, Qt::LeftButton); + QTRY_COMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode); + m_widget = nullptr; + m_applyButton = nullptr; + } + + { + // DO NOT autosave during close -- step 8's CustomData write already + // persisted, an extra save would trigger another sync we don't want + // to wait on. Same tradeoff acknowledgement as the Dropbox workflow: + // markAsClean here masks a "step-8 write wasn't autosaved" regression, + // but the downstream JSON-equality assertion below catches that case + // indirectly. + m_db->markAsClean(); + MessageBox::setNextAnswer(MessageBox::No); + triggerAction("actionDatabaseClose"); + QApplication::processEvents(); + MessageBox::setNextAnswer(MessageBox::NoButton); + delete m_dbWidget; + m_db.reset(); + } + + // Flip the mock back to first-sync mode BEFORE reopen -- the post-unlock + // sync-on-open path will call download(), and we want it to take + // SyncEngine's "remote not found" branch (no fs source touch). + MockNextcloudSyncProvider::setDownloadSourcePath(QString()); + // Re-arm the kill switch: the legitimate sync-on-open should fire when + // the reopened db unlocks. We engage the kill switch again right after. + MockNextcloudSyncProvider::setIsAuthorizedOverride(true); + + { + m_mainWindow->activateWindow(); + QApplication::processEvents(); + fileDialog()->setNextFileName(m_dbFilePath); + triggerAction("actionDatabaseOpen"); + QApplication::processEvents(); + + m_dbWidget = m_tabWidget->currentDatabaseWidget(); + QVERIFY(m_dbWidget); + // Spy MUST be created BEFORE the password Enter -- the mock-provider + // sync is fast enough that databaseSyncCompleted can fire between + // Enter-keypress and a spy created later, making a wait() on the + // late-bound spy hang forever. + QSignalSpy reopenSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + + auto* databaseOpenWidget = m_dbWidget->findChild("databaseOpenWidget"); + QVERIFY(databaseOpenWidget); + auto* editPassword = + databaseOpenWidget->findChild("editPassword")->findChild("passwordEdit"); + QVERIFY(editPassword); + editPassword->setFocus(); + QTRY_VERIFY(editPassword->hasFocus()); + QTest::keyClicks(editPassword, "a"); + QTest::keyClick(editPassword, Qt::Key_Enter); + + QTRY_VERIFY(!m_dbWidget->isLocked()); + m_db = m_dbWidget->database(); + // CRITICAL: poll for sync-on-unlock. If syncOnOpen flips off by + // default or onDatabaseUnlockedTriggerSync drops its dispatch, this + // times out -- pinning the contract that opening an authorized + // database triggers a sync. + if (reopenSyncSpy.count() == 0) { + QVERIFY(reopenSyncSpy.wait(5000)); + } + MockNextcloudSyncProvider::setIsAuthorizedOverride(false); + QCOMPARE(reopenSyncSpy.count(), 1); + QCOMPARE(reopenSyncSpy.at(0).at(0).toString(), QStringLiteral("Nextcloud")); + } + + // CRITICAL: persisted JSON read from the freshly-opened db must match + // what was on disk before close. A save/load round-trip that drops fields + // (e.g. appPassword not written, serverBaseUrl missing) would fail here. + { + RemoteSettings rs(m_db, nullptr); + QJsonObject after = + rs.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")); + QCOMPARE(after[QStringLiteral("type")].toString(), + nextcloudConfigBeforeClose[QStringLiteral("type")].toString()); + QCOMPARE(after[QStringLiteral("loginName")].toString(), + nextcloudConfigBeforeClose[QStringLiteral("loginName")].toString()); + QCOMPARE(after[QStringLiteral("appPassword")].toString(), + nextcloudConfigBeforeClose[QStringLiteral("appPassword")].toString()); + QCOMPARE(after[QStringLiteral("serverBaseUrl")].toString(), + nextcloudConfigBeforeClose[QStringLiteral("serverBaseUrl")].toString()); + QCOMPARE(after[QStringLiteral("remotePath")].toString(), + nextcloudConfigBeforeClose[QStringLiteral("remotePath")].toString()); + QCOMPARE(rs.activeProvider(), QStringLiteral("nextcloud")); + } + + // ---- Step 15: reopen Cloud Sync after db reopen ---------------------- + MockNextcloudSyncProvider::setDownloadSourcePath(canonicalKdbxPath); + openCloudSyncSettings(); + banner = m_widget->findChild(QStringLiteral("messageWidget")); + comboBox = m_widget->findChild(QStringLiteral("providerComboBox")); + nextcloudPage = m_widget->findChild(QStringLiteral("nextcloudPage")); + serverBaseUrlEdit = findInNextcloudPage(m_widget, "serverBaseUrlEdit"); + remotePathEdit = findInNextcloudPage(m_widget, "remotePathEdit"); + authorizeButton = findInNextcloudPage(m_widget, "authorizeButton"); + testConnectionButton = findInNextcloudPage(m_widget, "testConnectionButton"); + removeButton = findInNextcloudPage(m_widget, "removeButton"); + authStatusLabel = findInNextcloudPage(m_widget, "authStatusLabel"); + appPasswordGroupBox = findInNextcloudPage(m_widget, "appPasswordGroupBox"); + loginNameEdit = findInNextcloudPage(m_widget, "loginNameEdit"); + appPasswordEdit = findInNextcloudPage(m_widget, "appPasswordEdit"); + QVERIFY(banner); + QVERIFY(comboBox); + QVERIFY(nextcloudPage); + QVERIFY(serverBaseUrlEdit); + QVERIFY(remotePathEdit); + QVERIFY(authorizeButton); + QVERIFY(testConnectionButton); + QVERIFY(removeButton); + QVERIFY(authStatusLabel); + QVERIFY(appPasswordGroupBox); + QVERIFY(loginNameEdit); + QVERIFY(appPasswordEdit); + + QCOMPARE(comboBox->currentText(), QStringLiteral("Nextcloud")); + QCOMPARE(comboBox->currentIndex(), 1); + QVERIFY(nextcloudPage->isVisible()); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + + QTest::mouseClick(testConnectionButton, Qt::LeftButton); + QTRY_COMPARE(banner->text(), QStringLiteral("Nextcloud connection successful.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + QCOMPARE(authStatusLabel->text(), QStringLiteral("Authorized as test-login-alice")); + + // ---- Step 16: Remove -> banner + UI cleared + JSON gone -------------- + QTest::mouseClick(removeButton, Qt::LeftButton); + // CRITICAL: the post-Remove banner is the user-facing proof. Exact text + // comes from NextcloudCloudSyncPage::onRemoveClicked -- a regression that + // drops the emit (or shortens the two-sentence form to one) would leave + // the user wondering whether Remove worked at all, and whether they need + // to do anything server-side. + QTRY_COMPARE(banner->text(), + QStringLiteral("Nextcloud configuration removed. " + "To revoke the app-password server-side, visit your Nextcloud Security page.")); + QCOMPARE(banner->messageType(), KMessageWidget::Positive); + // The auth status label must flip back to "Not authorized" -- the reverse + // of step 5's transition. + QCOMPARE(authStatusLabel->text(), QStringLiteral("Not authorized")); + // Fields cleared under QSignalBlockers in onRemoveClicked; the placeholder + // text re-surfaces in the now-empty line edits. Assert on placeholderText, + // not text(), because text() is empty after the clear. + QVERIFY(serverBaseUrlEdit->text().isEmpty()); + QVERIFY(remotePathEdit->text().isEmpty()); + QVERIFY(loginNameEdit->text().isEmpty()); + QVERIFY(appPasswordEdit->text().isEmpty()); + QCOMPARE(loginNameEdit->placeholderText(), QStringLiteral("alice")); + QCOMPARE(appPasswordEdit->placeholderText(), QStringLiteral("xxxx-xxxx-xxxx-xxxx")); + // "Use App Password Instead" unchecked after Remove (onRemoveClicked + // calls setChecked(false) under a QSignalBlocker), children grayed. + QVERIFY(!appPasswordGroupBox->isChecked()); + QVERIFY(!loginNameEdit->isEnabled()); + QVERIFY(!appPasswordEdit->isEnabled()); + // CRITICAL: Apply must be grayed after Remove. m_modified is reset to + // false in onRemoveClicked. Apply re-enable here would be especially bad: + // a subsequent Apply would have nothing to save (saveToConfig returns + // empty for fresh-no-edit) but would still re-stamp other widgets' state. + QVERIFY(!m_applyButton->isEnabled()); + // JSON must be gone from CustomData -- onRemoveClicked persisted the + // removal via m_remoteSettings->removeProviderConfig + saveSettings. + { + RemoteSettings rs(m_db, nullptr); + QVERIFY(rs.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")).isEmpty()); + } + + // ---- Step 17: OK -> save fires, NO remote sync triggered ------------- + // After Remove, persisted CustomData no longer has a Nextcloud entry; the + // kill switch is engaged so SyncEngine's isAuthorized check returns false. + // OK triggers saveAllSettings -> General page re-stamps SettingsChanged + // -> Database modified -> autosave -> databaseSaved. The queued + // onDatabaseSavedTriggerSync runs but isCloudSyncAuthorized returns false, + // so no sync starts. + QSignalSpy postRemoveSavedSpy(m_db.data(), &Database::databaseSaved); + QSignalSpy postRemoveSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + closeDatabaseSettingsViaOk(); + // CRITICAL: the save itself must run -- confirms the cleared CustomData + // entry actually lands on disk, not just in memory. A regression that + // drops the autosave (e.g. an over-eager m_blockAutoSave) would silently + // leave the nextcloud CustomData entry on disk and the next open would + // re-resurrect the provider. + QVERIFY(postRemoveSavedSpy.wait(5000)); + QVERIFY(postRemoveSavedSpy.count() >= 1); + // CRITICAL: zero syncs after Remove + OK. Drain pending events so any + // queued slot has a chance to run before we check. + QTest::qWait(200); + QCOMPARE(postRemoveSyncSpy.count(), 0); + // CRITICAL: on-disk JSON has no nextcloud entry. A regression that re- + // adds the provider during OK's saveAllSettings (e.g. saveToConfig + // returning a non-empty config for an empty form) would re-resurrect it. + { + RemoteSettings rs(m_db, nullptr); + QVERIFY(rs.getProviderConfig(QStringLiteral("nextcloud"), QStringLiteral("nextcloud-default")).isEmpty()); + QVERIFY(rs.activeProvider().isEmpty()); + } + + // Step-17's JSON-empty + activeProvider-empty checks already prove the + // Remove + OK round-trip wiped CustomData. We deliberately do NOT reopen + // the settings dialog to assert "which provider page does it default + // to?" -- the dialog persists its combobox selection across reopens, and + // pinning either "stays on Nextcloud" or "resets to Dropbox" would lock + // in a UX detail the product doesn't currently care to specify. + + // Reset the mock so it doesn't bleed into other tests in this binary. + MockNextcloudSyncProvider::setDownloadSourcePath(QString()); +} diff --git a/tests/gui/TestCloudSyncWidget.h b/tests/gui/TestCloudSyncWidget.h new file mode 100644 index 0000000000..17ffec2ff9 --- /dev/null +++ b/tests/gui/TestCloudSyncWidget.h @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTCLOUDSYNCWIDGET_H +#define KEEPASSXC_TESTCLOUDSYNCWIDGET_H + +#include "gui/MainWindow.h" +#include "util/TemporaryFile.h" + +#include +#include +#include +#include + +class Database; +class DatabaseSettingsWidgetCloudSync; +class DatabaseTabWidget; +class DatabaseWidget; +class QPushButton; + +class TestCloudSyncWidget : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void init(); + void cleanup(); + void cleanupTestCase(); + + void CloudSettingNotImpactedWhileExploringOtherProviders(); + void CloudSettingSwitchProviderRemoveOldOne(); + void CloudSettingMenuEntry(); + void CloudSettingAddAndRemoveDropboxFullWorkflow(); + void CloudSettingAddAndRemoveNextCloudFullWorkflow(); + +private: + void triggerAction(const QString& name); + void openCloudSyncSettings(); + void closeDatabaseSettingsViaOk(); + + QScopedPointer m_mainWindow; + QPointer m_tabWidget; + QPointer m_dbWidget; + QSharedPointer m_db; + DatabaseSettingsWidgetCloudSync* m_widget = nullptr; // borrowed -- lives on DatabaseSettingsDialog + QPushButton* m_applyButton = nullptr; // borrowed -- lives on DatabaseSettingsDialog's buttonBox + TemporaryFile m_dbFile; + QString m_dbFilePath; +}; + +#endif // KEEPASSXC_TESTCLOUDSYNCWIDGET_H diff --git a/tests/mock/MockDropboxLoginFlow.cpp b/tests/mock/MockDropboxLoginFlow.cpp new file mode 100644 index 0000000000..eccb90f05d --- /dev/null +++ b/tests/mock/MockDropboxLoginFlow.cpp @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "MockDropboxLoginFlow.h" + +MockDropboxLoginFlow::MockDropboxLoginFlow(QObject* parent) + : DropboxLoginFlow(parent) +{ +} + +void MockDropboxLoginFlow::setNextStartOutcome(StartOutcome outcome) +{ + m_nextStartOutcome = outcome; +} + +void MockDropboxLoginFlow::setNextSubmitOutcome(SubmitOutcome outcome) +{ + m_nextSubmitOutcome = outcome; +} + +void MockDropboxLoginFlow::setCannedTokens(const QString& accessToken, const QString& refreshToken, qint64 expiresAtMs) +{ + m_accessToken = accessToken; + m_refreshToken = refreshToken; + m_expiresAtMs = expiresAtMs; +} + +void MockDropboxLoginFlow::setCannedManualVerifier(const QString& codeVerifier) +{ + m_codeVerifier = codeVerifier; +} + +void MockDropboxLoginFlow::setCannedFailureReason(const QString& reason) +{ + m_failureReason = reason; +} + +void MockDropboxLoginFlow::startAuthorization(const QString& /*appKey*/, int /*timeoutMs*/) +{ + ++m_startCount; + emitStartOutcome(); +} + +void MockDropboxLoginFlow::submitManualCode(const QString& authCode, int /*timeoutMs*/) +{ + ++m_submitCount; + m_lastSubmittedCode = authCode; + emitSubmitOutcome(); +} + +void MockDropboxLoginFlow::cancel() +{ + ++m_cancelCount; + // Cancel is the page-side's "stop the flow" entry; emit cancelled to + // mirror the real flow's terminal transition. Tests that don't want the + // cancellation signal can ignore it. + emit authorizationCancelled(); +} + +void MockDropboxLoginFlow::emitStartOutcome() +{ + switch (m_nextStartOutcome) { + case StartOutcome::ManualFallback: + emit authorizationManualFallback(m_codeVerifier); + return; + case StartOutcome::Completed: + emit authorizationCompleted(m_accessToken, m_refreshToken, m_expiresAtMs); + return; + case StartOutcome::Failed: + emit authorizationFailed(m_failureReason); + return; + case StartOutcome::Cancelled: + emit authorizationCancelled(); + return; + } +} + +void MockDropboxLoginFlow::emitSubmitOutcome() +{ + switch (m_nextSubmitOutcome) { + case SubmitOutcome::Completed: + emit authorizationCompleted(m_accessToken, m_refreshToken, m_expiresAtMs); + return; + case SubmitOutcome::Failed: + emit authorizationFailed(m_failureReason); + return; + case SubmitOutcome::Cancelled: + emit authorizationCancelled(); + return; + } +} diff --git a/tests/mock/MockDropboxLoginFlow.h b/tests/mock/MockDropboxLoginFlow.h new file mode 100644 index 0000000000..2141286974 --- /dev/null +++ b/tests/mock/MockDropboxLoginFlow.h @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_MOCKDROPBOXLOGINFLOW_H +#define KEEPASSXC_MOCKDROPBOXLOGINFLOW_H + +#include "remotesync/DropboxLoginFlow.h" + +// Test double for DropboxLoginFlow: overrides the three virtuals +// (startAuthorization / submitManualCode / cancel) so the page can drive its +// auth state machine without real PKCE / browser-open / OAuthHttpServer +// machinery. Tests configure the outcome of the next start/submit call ahead +// of time; the mock emits the matching terminal signal synchronously when +// the page invokes the virtual. +class MockDropboxLoginFlow : public DropboxLoginFlow +{ + Q_OBJECT + +public: + enum class StartOutcome + { + ManualFallback, + Completed, + Failed, + Cancelled, + }; + + enum class SubmitOutcome + { + Completed, + Failed, + Cancelled, + }; + + explicit MockDropboxLoginFlow(QObject* parent = nullptr); + ~MockDropboxLoginFlow() override = default; + + void setNextStartOutcome(StartOutcome outcome); + void setNextSubmitOutcome(SubmitOutcome outcome); + void setCannedTokens(const QString& accessToken, const QString& refreshToken, qint64 expiresAtMs); + void setCannedManualVerifier(const QString& codeVerifier); + void setCannedFailureReason(const QString& reason); + + void startAuthorization(const QString& appKey, int timeoutMs) override; + void submitManualCode(const QString& authCode, int timeoutMs) override; + void cancel() override; + + int startCount() const { return m_startCount; } + int submitCount() const { return m_submitCount; } + int cancelCount() const { return m_cancelCount; } + QString lastSubmittedCode() const { return m_lastSubmittedCode; } + +private: + void emitStartOutcome(); + void emitSubmitOutcome(); + + StartOutcome m_nextStartOutcome = StartOutcome::Completed; + SubmitOutcome m_nextSubmitOutcome = SubmitOutcome::Completed; + + QString m_accessToken; + QString m_refreshToken; + qint64 m_expiresAtMs = 0; + QString m_codeVerifier; + QString m_failureReason; + + int m_startCount = 0; + int m_submitCount = 0; + int m_cancelCount = 0; + QString m_lastSubmittedCode; +}; + +#endif // KEEPASSXC_MOCKDROPBOXLOGINFLOW_H diff --git a/tests/mock/MockDropboxSyncProvider.cpp b/tests/mock/MockDropboxSyncProvider.cpp new file mode 100644 index 0000000000..e47da420e4 --- /dev/null +++ b/tests/mock/MockDropboxSyncProvider.cpp @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "MockDropboxSyncProvider.h" + +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include + +QString MockDropboxSyncProvider::s_downloadSourcePath; +QString MockDropboxSyncProvider::s_nextDownloadFailureMessage; +RemoteHandler::ErrorKind MockDropboxSyncProvider::s_nextDownloadFailureKind = RemoteHandler::ErrorKind::Other; +int MockDropboxSyncProvider::s_downloadCallCount = 0; +int MockDropboxSyncProvider::s_uploadCallCount = 0; +int MockDropboxSyncProvider::s_refreshAuthCallCount = 0; +int MockDropboxSyncProvider::s_revokeTokenCallCount = 0; +bool MockDropboxSyncProvider::s_isAuthorizedOverride = true; + +MockDropboxSyncProvider::MockDropboxSyncProvider(QObject* parent) + : DropboxSyncProvider(parent) +{ +} + +void MockDropboxSyncProvider::setDownloadSourcePath(const QString& path) +{ + s_downloadSourcePath = path; +} + +void MockDropboxSyncProvider::setNextDownloadFailure(const QString& errorMessage, RemoteHandler::ErrorKind kind) +{ + s_nextDownloadFailureMessage = errorMessage; + s_nextDownloadFailureKind = kind; +} + +int MockDropboxSyncProvider::downloadCallCount() +{ + return s_downloadCallCount; +} + +int MockDropboxSyncProvider::uploadCallCount() +{ + return s_uploadCallCount; +} + +int MockDropboxSyncProvider::refreshAuthCallCount() +{ + return s_refreshAuthCallCount; +} + +int MockDropboxSyncProvider::revokeTokenCallCount() +{ + return s_revokeTokenCallCount; +} + +void MockDropboxSyncProvider::resetCallCounts() +{ + s_downloadCallCount = 0; + s_uploadCallCount = 0; + s_refreshAuthCallCount = 0; + s_revokeTokenCallCount = 0; +} + +void MockDropboxSyncProvider::setIsAuthorizedOverride(bool authorized) +{ + s_isAuthorizedOverride = authorized; +} + +bool MockDropboxSyncProvider::isAuthorized(const QJsonObject& config) const +{ + if (!s_isAuthorizedOverride) { + return false; + } + return DropboxSyncProvider::isAuthorized(config); +} + +RemoteHandler::RemoteResult MockDropboxSyncProvider::refreshAuth(const RemoteSyncParams* /*params*/) +{ + ++s_refreshAuthCallCount; + // Empty stdOutput: provider declares "access token still valid, no rotation". + // SyncEngine::doAuthenticate skips applyRefreshedTokens in this case. + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockDropboxSyncProvider::download(const RemoteSyncParams* /*params*/) +{ + ++s_downloadCallCount; + + // One-shot failure injection: tests use this to drive the Test Connection + // / sync failure paths without rebuilding the factory override. + if (!s_nextDownloadFailureMessage.isEmpty()) { + QString msg = s_nextDownloadFailureMessage; + auto kind = s_nextDownloadFailureKind; + s_nextDownloadFailureMessage.clear(); + s_nextDownloadFailureKind = RemoteHandler::ErrorKind::Other; + return {false, msg, {}, {}, {}, kind}; + } + + // One-shot consumption of the source path. The page-side Test Connection + // sets the source once and expects one canonical-file copy; ANY later + // download() (e.g. SyncEngine's sync-on-save / sync-on-open) must NOT + // re-copy the same canonical file. Otherwise SyncEngine.doMerge runs + // against the canonical kdbx every iteration -- since the canonical + // kdbx has different timestamps than the live local db, Merger records + // trivial history-item changes which leave m_modified=true, doSave + // re-fires databaseSaved, and the queued onDatabaseSavedTriggerSync + // (Qt::QueuedConnection at DatabaseWidget.cpp:1578, runs AFTER + // m_syncInProgress is cleared) loops into the next sync. By consuming + // the source, every chained sync sees filePath="" -> SyncEngine takes + // the first-sync branch (SyncEngine.cpp:154) -> doSave on a now-clean + // db -> markAsClean sees m_modified=false -> no databaseSaved emit -> + // loop terminates after one iteration. + const QString sourcePath = s_downloadSourcePath; + s_downloadSourcePath.clear(); + + // No canned source -> "remote file does not exist yet" first-sync mode. + // SyncEngine treats {success=true, filePath=""} as the file-not-found + // signal and skips merge, going straight to local save + upload. + if (sourcePath.isEmpty() || !QFileInfo::exists(sourcePath)) { + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; + } + + // Stream source -> brand-new temp path, NO QTemporaryFile + QFile::copy + // dance. The earlier dance (open QTemporaryFile, close it, QFile::remove + // the placeholder, QFile::copy on top) hit a Windows-specific failure: + // QTemporaryFile retains an internal lock on its path even after close() + // -- the subsequent remove silently no-ops and QFile::copy refuses to + // overwrite. Building a unique path with QUuid and writing src bytes + // directly into a freshly-created QFile dodges every Windows file-share + // corner. Caller (page or SyncEngine) is still responsible for + // QFile::remove of the returned path. + const QString outPath = QDir::tempPath() + QStringLiteral("/keepassxc_mock_dropbox_") + + QUuid::createUuid().toString(QUuid::WithoutBraces) + + QStringLiteral(".kdbx"); + + QFile src(sourcePath); + if (!src.open(QIODevice::ReadOnly)) { + return {false, + QStringLiteral("MockDropboxSyncProvider: failed to open source %1: %2") + .arg(sourcePath, src.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + const QByteArray data = src.readAll(); + src.close(); + + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly)) { + return {false, + QStringLiteral("MockDropboxSyncProvider: failed to open dest %1: %2") + .arg(outPath, out.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + if (out.write(data) != data.size()) { + out.close(); + QFile::remove(outPath); + return {false, + QStringLiteral("MockDropboxSyncProvider: failed to write dest %1: %2") + .arg(outPath, out.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + out.close(); + + return {true, {}, outPath, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockDropboxSyncProvider::upload(const QString& /*filePath*/, + const RemoteSyncParams* /*params*/) +{ + ++s_uploadCallCount; + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockDropboxSyncProvider::revokeToken(const DropboxSyncParams* /*params*/) +{ + ++s_revokeTokenCallCount; + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} diff --git a/tests/mock/MockDropboxSyncProvider.h b/tests/mock/MockDropboxSyncProvider.h new file mode 100644 index 0000000000..8c388c9de9 --- /dev/null +++ b/tests/mock/MockDropboxSyncProvider.h @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_MOCKDROPBOXSYNCPROVIDER_H +#define KEEPASSXC_MOCKDROPBOXSYNCPROVIDER_H + +#include "remotesync/DropboxSyncProvider.h" + +// Test double for DropboxSyncProvider: replaces all four network-fronted +// operations (download / upload / refreshAuth / revokeToken) with canned +// successes. Drop-in via RemoteSyncProvider::setFactoryOverrideForTest -- +// callers must NOT call setNetworkAccessManager on the mock (the overrides +// never touch QNAM). +// +// Default behavior is "happy path for an already-authorized user": +// * refreshAuth -> success, empty stdOutput (no token rotation) +// * download -> success; filePath copied from s_downloadSourcePath if set, +// empty otherwise (= "remote file does not exist yet", +// SyncEngine takes the first-sync branch and skips merge) +// * upload -> success +// * revokeToken -> success +// +// Per-test customization is via the static setters below. Because the page +// and DatabaseWidget each create() their own provider instance, instance-level +// configuration would not reach both -- statics are the legitimate exemption. +class MockDropboxSyncProvider : public DropboxSyncProvider +{ + Q_OBJECT + +public: + explicit MockDropboxSyncProvider(QObject* parent = nullptr); + ~MockDropboxSyncProvider() override = default; + + RemoteHandler::RemoteResult download(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult revokeToken(const DropboxSyncParams* params) override; + + // Set the source .kdbx file that the NEXT download() call will copy to + // a temp path and return. One-shot: download() consumes the source and + // clears it, so any subsequent download() falls back to "remote file + // not found" (filePath=""). This breaks the chained-sync loop that + // would otherwise result from re-merging a static canonical file every + // iteration (the merger logs trivial history changes -> m_modified=true + // -> save re-emits databaseSaved -> queued onDatabaseSavedTriggerSync + // starts another sync). Passing an empty string explicitly resets to + // first-sync mode without consuming a stored source. + static void setDownloadSourcePath(const QString& path); + + // Set the next failure to return from download(). After being returned + // once, the failure is cleared and subsequent calls revert to success. + // Used to inject Test Connection / sync failure scenarios. + static void setNextDownloadFailure(const QString& errorMessage, + RemoteHandler::ErrorKind kind = RemoteHandler::ErrorKind::Other); + + // Test-only chain breaker for the post-sync re-trigger loop. When set + // to false, isAuthorized() returns false unconditionally -- which makes + // both DatabaseWidget::isCloudSyncAuthorized() (in + // onDatabaseSavedTriggerSync) and syncWithCloud()'s own isAuthorized() + // check (DatabaseWidget.cpp:1264) short-circuit, so no new sync starts. + // Use this to assert "this user action did NOT trigger a sync" without + // being polluted by the background save->databaseSaved->queued-slot->sync + // chain (the chain runs because Database::save updates RandomSlug on + // every save, which always re-emits databaseSaved -- mock-fast saves + // never let the 150ms m_modifiedTimer fire to reload RemoteSettings). + // Default true (matches production behavior). + static void setIsAuthorizedOverride(bool authorized); + + bool isAuthorized(const QJsonObject& config) const override; + + // Call counters for assertions. + static int downloadCallCount(); + static int uploadCallCount(); + static int refreshAuthCallCount(); + static int revokeTokenCallCount(); + static void resetCallCounts(); + +private: + static QString s_downloadSourcePath; + static QString s_nextDownloadFailureMessage; + static RemoteHandler::ErrorKind s_nextDownloadFailureKind; + static int s_downloadCallCount; + static int s_uploadCallCount; + static int s_refreshAuthCallCount; + static int s_revokeTokenCallCount; + static bool s_isAuthorizedOverride; +}; + +#endif // KEEPASSXC_MOCKDROPBOXSYNCPROVIDER_H diff --git a/tests/mock/MockNextcloudLoginFlow.cpp b/tests/mock/MockNextcloudLoginFlow.cpp new file mode 100644 index 0000000000..8edab4a3cf --- /dev/null +++ b/tests/mock/MockNextcloudLoginFlow.cpp @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "MockNextcloudLoginFlow.h" + +MockNextcloudLoginFlow::MockNextcloudLoginFlow(QObject* parent) + : NextcloudLoginFlow(parent) +{ +} + +void MockNextcloudLoginFlow::setNextStartOutcome(StartOutcome outcome) +{ + m_nextStartOutcome = outcome; +} + +void MockNextcloudLoginFlow::setCannedCreds(const QString& loginName, const QString& appPassword) +{ + m_loginName = loginName; + m_appPassword = appPassword; +} + +void MockNextcloudLoginFlow::setCannedFailureReason(const QString& reason) +{ + m_failureReason = reason; +} + +void MockNextcloudLoginFlow::startLoginFlow(const QString& serverBaseUrl) +{ + ++m_startCount; + m_lastServerBaseUrl = serverBaseUrl; + emitStartOutcome(); +} + +void MockNextcloudLoginFlow::cancel() +{ + ++m_cancelCount; + // Cancel is the page-side's "stop the flow" entry; emit cancelled to + // mirror the real flow's terminal transition. Tests that don't want the + // cancellation signal can ignore it. + emit loginCancelled(); +} + +void MockNextcloudLoginFlow::emitStartOutcome() +{ + switch (m_nextStartOutcome) { + case StartOutcome::Completed: + emit loginCompleted(m_loginName, m_appPassword); + return; + case StartOutcome::Failed: + emit loginFailed(m_failureReason); + return; + case StartOutcome::Cancelled: + emit loginCancelled(); + return; + } +} diff --git a/tests/mock/MockNextcloudLoginFlow.h b/tests/mock/MockNextcloudLoginFlow.h new file mode 100644 index 0000000000..b47cfe922d --- /dev/null +++ b/tests/mock/MockNextcloudLoginFlow.h @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_MOCKNEXTCLOUDLOGINFLOW_H +#define KEEPASSXC_MOCKNEXTCLOUDLOGINFLOW_H + +#include "remotesync/NextcloudLoginFlow.h" + +// Test double for NextcloudLoginFlow: overrides startLoginFlow / cancel so the +// page can drive its auth state machine without real Login Flow v2 POST / +// browser-open / 5-second polling machinery. Tests configure the outcome of +// the next start call ahead of time; the mock emits the matching terminal +// signal synchronously when the page invokes the virtual. +// +// Mirrors MockDropboxLoginFlow's shape -- StartOutcome enum, set/canned +// helpers, call counters -- minus the manual-fallback path (Nextcloud Login +// Flow v2 has no analogue of Dropbox's PKCE manual-code fallback). +class MockNextcloudLoginFlow : public NextcloudLoginFlow +{ + Q_OBJECT + +public: + enum class StartOutcome + { + Completed, + Failed, + Cancelled, + }; + + explicit MockNextcloudLoginFlow(QObject* parent = nullptr); + ~MockNextcloudLoginFlow() override = default; + + void setNextStartOutcome(StartOutcome outcome); + void setCannedCreds(const QString& loginName, const QString& appPassword); + void setCannedFailureReason(const QString& reason); + + void startLoginFlow(const QString& serverBaseUrl) override; + void cancel() override; + + int startCount() const { return m_startCount; } + int cancelCount() const { return m_cancelCount; } + QString lastServerBaseUrl() const { return m_lastServerBaseUrl; } + +private: + void emitStartOutcome(); + + StartOutcome m_nextStartOutcome = StartOutcome::Completed; + + QString m_loginName; + QString m_appPassword; + QString m_failureReason; + + int m_startCount = 0; + int m_cancelCount = 0; + QString m_lastServerBaseUrl; +}; + +#endif // KEEPASSXC_MOCKNEXTCLOUDLOGINFLOW_H diff --git a/tests/mock/MockNextcloudSyncProvider.cpp b/tests/mock/MockNextcloudSyncProvider.cpp new file mode 100644 index 0000000000..1274e20a46 --- /dev/null +++ b/tests/mock/MockNextcloudSyncProvider.cpp @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "MockNextcloudSyncProvider.h" + +#include "remotesync/RemoteSyncParams.h" + +#include +#include +#include +#include + +QString MockNextcloudSyncProvider::s_downloadSourcePath; +QString MockNextcloudSyncProvider::s_nextDownloadFailureMessage; +RemoteHandler::ErrorKind MockNextcloudSyncProvider::s_nextDownloadFailureKind = RemoteHandler::ErrorKind::Other; +int MockNextcloudSyncProvider::s_downloadCallCount = 0; +int MockNextcloudSyncProvider::s_uploadCallCount = 0; +int MockNextcloudSyncProvider::s_refreshAuthCallCount = 0; +int MockNextcloudSyncProvider::s_testConnectionCallCount = 0; +bool MockNextcloudSyncProvider::s_isAuthorizedOverride = true; + +MockNextcloudSyncProvider::MockNextcloudSyncProvider(QObject* parent) + : NextcloudSyncProvider(parent) +{ +} + +void MockNextcloudSyncProvider::setDownloadSourcePath(const QString& path) +{ + s_downloadSourcePath = path; +} + +void MockNextcloudSyncProvider::setNextDownloadFailure(const QString& errorMessage, RemoteHandler::ErrorKind kind) +{ + s_nextDownloadFailureMessage = errorMessage; + s_nextDownloadFailureKind = kind; +} + +void MockNextcloudSyncProvider::setIsAuthorizedOverride(bool authorized) +{ + s_isAuthorizedOverride = authorized; +} + +int MockNextcloudSyncProvider::downloadCallCount() +{ + return s_downloadCallCount; +} + +int MockNextcloudSyncProvider::uploadCallCount() +{ + return s_uploadCallCount; +} + +int MockNextcloudSyncProvider::refreshAuthCallCount() +{ + return s_refreshAuthCallCount; +} + +int MockNextcloudSyncProvider::testConnectionCallCount() +{ + return s_testConnectionCallCount; +} + +void MockNextcloudSyncProvider::resetCallCounts() +{ + s_downloadCallCount = 0; + s_uploadCallCount = 0; + s_refreshAuthCallCount = 0; + s_testConnectionCallCount = 0; +} + +bool MockNextcloudSyncProvider::isAuthorized(const QJsonObject& config) const +{ + if (!s_isAuthorizedOverride) { + return false; + } + return NextcloudSyncProvider::isAuthorized(config); +} + +RemoteHandler::RemoteResult MockNextcloudSyncProvider::refreshAuth(const RemoteSyncParams* /*params*/) +{ + ++s_refreshAuthCallCount; + // Empty stdOutput: provider declares "auth still valid, no rotation". + // SyncEngine::doAuthenticate skips applyRefreshedTokens in this case. + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockNextcloudSyncProvider::testConnection(const NextcloudSyncParams* /*params*/) +{ + ++s_testConnectionCallCount; + + // Non-consuming peek (unlike download() below). Test Connection clicks are + // safe to repeat against the same canned source. The page surface + // distinguishes filePath="" ("Connected. File not found -- it will be + // created on first sync.") from filePath != "" ("Nextcloud connection + // successful."); we mirror that contract here so the user-visible banner + // depends only on whether the test set a source. + if (!s_downloadSourcePath.isEmpty() && QFileInfo::exists(s_downloadSourcePath)) { + return {true, {}, s_downloadSourcePath, {}, {}, RemoteHandler::ErrorKind::Other}; + } + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockNextcloudSyncProvider::download(const RemoteSyncParams* /*params*/) +{ + ++s_downloadCallCount; + + // One-shot failure injection (mirrors MockDropboxSyncProvider). + if (!s_nextDownloadFailureMessage.isEmpty()) { + QString msg = s_nextDownloadFailureMessage; + auto kind = s_nextDownloadFailureKind; + s_nextDownloadFailureMessage.clear(); + s_nextDownloadFailureKind = RemoteHandler::ErrorKind::Other; + return {false, msg, {}, {}, {}, kind}; + } + + // One-shot consumption of the source path -- same chain-breaker rationale + // as MockDropboxSyncProvider::download (the verbatim block-comment + // explanation lives there; the contract is identical here). + const QString sourcePath = s_downloadSourcePath; + s_downloadSourcePath.clear(); + + if (sourcePath.isEmpty() || !QFileInfo::exists(sourcePath)) { + // First-sync mode: SyncEngine treats {success=true, filePath=""} as + // file-not-found and skips merge, going straight to local save + + // upload. + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; + } + + // Stream source -> brand-new temp path (same Windows-share-friendly pattern + // as MockDropboxSyncProvider::download). + const QString outPath = QDir::tempPath() + QStringLiteral("/keepassxc_mock_nextcloud_") + + QUuid::createUuid().toString(QUuid::WithoutBraces) + QStringLiteral(".kdbx"); + + QFile src(sourcePath); + if (!src.open(QIODevice::ReadOnly)) { + return {false, + QStringLiteral("MockNextcloudSyncProvider: failed to open source %1: %2") + .arg(sourcePath, src.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + const QByteArray data = src.readAll(); + src.close(); + + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly)) { + return {false, + QStringLiteral("MockNextcloudSyncProvider: failed to open dest %1: %2") + .arg(outPath, out.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + if (out.write(data) != data.size()) { + out.close(); + QFile::remove(outPath); + return {false, + QStringLiteral("MockNextcloudSyncProvider: failed to write dest %1: %2") + .arg(outPath, out.errorString()), + {}, + {}, + {}, + RemoteHandler::ErrorKind::Other}; + } + out.close(); + + return {true, {}, outPath, {}, {}, RemoteHandler::ErrorKind::Other}; +} + +RemoteHandler::RemoteResult MockNextcloudSyncProvider::upload(const QString& /*filePath*/, + const RemoteSyncParams* /*params*/) +{ + ++s_uploadCallCount; + return {true, {}, {}, {}, {}, RemoteHandler::ErrorKind::Other}; +} diff --git a/tests/mock/MockNextcloudSyncProvider.h b/tests/mock/MockNextcloudSyncProvider.h new file mode 100644 index 0000000000..374c994f4a --- /dev/null +++ b/tests/mock/MockNextcloudSyncProvider.h @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_MOCKNEXTCLOUDSYNCPROVIDER_H +#define KEEPASSXC_MOCKNEXTCLOUDSYNCPROVIDER_H + +#include "remotesync/NextcloudSyncProvider.h" + +struct NextcloudSyncParams; + +// Test double for NextcloudSyncProvider: replaces the network-fronted +// operations (download / upload / refreshAuth / testConnection) with canned +// successes. Drop-in via RemoteSyncProvider::setFactoryOverrideForTest -- the +// overrides never touch QNAM, so callers must NOT call setNetworkAccessManager +// on the mock. +// +// Mirrors MockDropboxSyncProvider's shape minus the revokeToken hook (Nextcloud +// Login Flow v2 has no server-side revoke endpoint -- onRemoveClicked clears +// local config only). Default behavior is "happy path for an already-authorized +// user": +// * refreshAuth -> success, empty stdOutput (no token rotation) +// * download -> success; filePath copied from s_downloadSourcePath if +// set, empty otherwise (= "remote file does not exist +// yet", SyncEngine takes the first-sync branch and skips +// merge) +// * upload -> success +// * testConnection -> success; filePath echoed from s_downloadSourcePath if +// set (= "Nextcloud connection successful."), empty +// otherwise (= "Connected. File not found ...") +// +// Per-test customization is via the static setters below. Because the page and +// DatabaseWidget each create() their own provider instance, instance-level +// configuration would not reach both -- statics are the legitimate exemption +// (same rationale as MockDropboxSyncProvider). +class MockNextcloudSyncProvider : public NextcloudSyncProvider +{ + Q_OBJECT + +public: + explicit MockNextcloudSyncProvider(QObject* parent = nullptr); + ~MockNextcloudSyncProvider() override = default; + + RemoteHandler::RemoteResult download(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult upload(const QString& filePath, const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult refreshAuth(const RemoteSyncParams* params) override; + RemoteHandler::RemoteResult testConnection(const NextcloudSyncParams* params) override; + + // Set the source .kdbx file that the NEXT download() / testConnection() + // call will copy to a temp path and return. download() is one-shot + // (consumes the source); testConnection() is NOT (it merely peeks at the + // source, so a single set persists across multiple Test Connection clicks + // until the test explicitly resets it). The asymmetry matches usage: + // SyncEngine's chained-sync loop drives download() and would re-merge a + // static canonical kdbx forever otherwise (see MockDropboxSyncProvider for + // the same one-shot rationale). + static void setDownloadSourcePath(const QString& path); + + // Set the next failure to return from download(). After being returned + // once, the failure is cleared and subsequent calls revert to success. + // Used to inject sync failure scenarios. + static void setNextDownloadFailure(const QString& errorMessage, + RemoteHandler::ErrorKind kind = RemoteHandler::ErrorKind::Other); + + // Test-only chain breaker for the post-sync re-trigger loop. When set to + // false, isAuthorized() returns false unconditionally -- same rationale and + // semantics as MockDropboxSyncProvider::setIsAuthorizedOverride. Default + // true (matches production behavior). + static void setIsAuthorizedOverride(bool authorized); + + bool isAuthorized(const QJsonObject& config) const override; + + // Call counters for assertions. + static int downloadCallCount(); + static int uploadCallCount(); + static int refreshAuthCallCount(); + static int testConnectionCallCount(); + static void resetCallCounts(); + +private: + static QString s_downloadSourcePath; + static QString s_nextDownloadFailureMessage; + static RemoteHandler::ErrorKind s_nextDownloadFailureKind; + static int s_downloadCallCount; + static int s_uploadCallCount; + static int s_refreshAuthCallCount; + static int s_testConnectionCallCount; + static bool s_isAuthorizedOverride; +}; + +#endif // KEEPASSXC_MOCKNEXTCLOUDSYNCPROVIDER_H