diff --git a/cmake/modules/MacOSXBundleInfo.plist.in b/cmake/modules/MacOSXBundleInfo.plist.in index 6be98bb4b17c7..19aa12042482f 100644 --- a/cmake/modules/MacOSXBundleInfo.plist.in +++ b/cmake/modules/MacOSXBundleInfo.plist.in @@ -45,6 +45,8 @@ NSRequiresAquaSystemAppearance + NSLocalNetworkUsageDescription + Nextcloud needs access to your local network to connect to Nextcloud servers hosted there. SUShowReleaseNotes SUPublicDSAKeyFile diff --git a/doc/local-network-permission.md b/doc/local-network-permission.md new file mode 100644 index 0000000000000..090f5d4ad4b38 --- /dev/null +++ b/doc/local-network-permission.md @@ -0,0 +1,262 @@ + + +# Local Network Permission Handling + +## Purpose + +On macOS 15 and later, the user can deny an application access to devices on +the local network. A connection attempt affected by this setting otherwise +looks much like an unreachable server to the desktop client. The local network +permission check lets the client replace a generic connection error with an +actionable message: + +> Local Network access is disabled. Enable it in System Settings → Privacy & +> Security → Local Network. + +This check is diagnostic rather than proactive. It runs after a server +connection has already failed or timed out. It does not request permission and +does not report a global permission state. Apple does not provide a general API +for querying that state; instead, the check observes the path of a connection +to the server the user entered. See +[TN3179: Understanding local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy). + +The implementation was introduced in response to +[nextcloud/desktop#10452](https://github.com/nextcloud/desktop/issues/10452). + +## Architecture + +### Platform boundary + +`LocalNetworkPermission` exposes two functions from +`src/gui/localnetworkpermission.h`: + +- `checkDeniedForConnection()` reports through a callback whether local network + permission denied a specific failed connection. +- `deniedError()` returns the platform-appropriate error shown to the user. + +CMake selects the implementation: + +- On macOS, `src/gui/macOS/localnetworkpermission.mm` uses Network.framework. +- On other platforms, `src/gui/localnetworkpermission.cpp` reports `false`, + preserving the existing connection error. + +This keeps platform conditionals out of `ConnectionValidator` and +`AccountWizardController`. A `false` result means that local network denial was +not established; it does not prove that permission is enabled. + +### macOS connection probe + +The macOS implementation is available on macOS 15 and later. It performs the +following steps: + +1. Extract the host and port from the failed URL. The default port is `443` for + HTTPS and `80` otherwise. +2. Create a Network.framework TCP connection to that endpoint. The probe does + not perform an HTTP request or a TLS handshake. +3. Set `prefer_no_proxy` on the connection parameters. This makes + Network.framework try the direct path first, so a VPN-provided local proxy + cannot immediately hide the local-network denial. Network.framework may + still try a configured proxy if the direct attempt fails. +4. Observe connection path and state updates on the main dispatch queue. +5. Finish with `true` when an unsatisfied path reports + `nw_path_unsatisfied_reason_local_network_denied`. +6. Finish with `false` when the connection becomes ready. +7. On a `waiting` or `failed` state, finish only if the current path explicitly + reports local-network denial. A path can be temporarily inconclusive, so + completing with `false` at this point would introduce a race with a later + path update. +8. After two seconds, inspect the path once more and finish. This bounds the + diagnostic delay when the server is merely absent or unreachable. + +`ConnectionProbe` owns the Network.framework connection and callback. Its +`completed` flag ensures exactly-once completion. Finishing cancels and +releases the connection before dispatching the result back through Qt. + +The callback context is held as a `QPointer`. The result is queued onto +that context and is discarded if the context has been destroyed, preventing a +callback into a deleted controller or validator. + +### Consumers + +`ConnectionValidator` invokes the check when: + +- the status request fails; or +- its connection job times out. + +When denial is established, the permission message replaces the generic +network error. The validator's status value is unchanged. + +`AccountWizardController` invokes the check after its server connection fails. +When denial is established, it displays the permission message and does not +offer secure-connection recovery, such as retrying without TLS. Otherwise, the +existing recovery flow continues. + +The wizard also verifies that the account URL still matches the URL whose +probe completed. This prevents a delayed result from an earlier attempt from +changing the state of a newer attempt. + +### Test seam + +Both consumers store the permission check in a private `std::function`, +initialized to `LocalNetworkPermission::checkDeniedForConnection`. Their test +access classes are friends and replace that callable with a synchronous +deterministic result. + +This keeps the production constructors and public API unchanged. It also tests +the behavior of each consumer without subclassing production classes or +making one-line methods virtual solely for tests. + +## Test plan + +### Automated coverage + +`AccountWizardControllerTest` covers: + +- An invalid URL produces a non-denied result. +- A denied result displays `deniedError()` and suppresses secure-connection + recovery. +- A non-denied result preserves the secure-connection recovery flow. + +`ConnectionValidatorTest` covers: + +- A denied result replaces the generic timeout text with `deniedError()`. + +Build and run the focused tests from the repository root: + +```sh +cmake -S . -B build-testing +cmake --build build-testing \ + --target AccountWizardControllerTest ConnectionValidatorTest +ctest --test-dir build-testing --output-on-failure \ + -R '^(ConnectionValidator|AccountWizardController)Test$' +``` + +### Manual coverage + +The automated tests do not cover: + +- macOS Local Network privacy enforcement or its System Settings toggle; +- Network.framework path-update ordering and unsatisfied reasons; +- behavior with a real VPN or system proxy; +- the direct-path preference and proxy fallback; +- code-signing identity and executable UUID tracking; +- differences between launching from Finder, Terminal, Xcode, or another + parent process; +- the complete two-second probe against a real network. + +These behaviors depend on operating-system privacy state, routing, signing, +and the active network environment. Checking only that a Network.framework +parameter was set would test an implementation detail, not the intended VPN +behavior. The native path therefore requires the manual regression test below. + +## Reproducing local-network denial + +### Requirements + +- macOS 15 or later. +- A validly signed application with a stable Apple-issued identity. +- A unique UUID in the main executable. +- `NSLocalNetworkUsageDescription` in the application `Info.plist`. +- A target address on a network directly attached through Wi-Fi or Ethernet. + A private address routed elsewhere is not necessarily a local-network + address for this privacy feature. + +The target does not need to run a Nextcloud server. Using an unused address on +the directly attached subnet is useful because it isolates privacy diagnosis +from server behavior. + +Verify the application before testing: + +```sh +codesign --verify --deep --strict --verbose=2 /path/to/Nextcloud.app +/usr/bin/dwarfdump --uuid /path/to/Nextcloud.app/Contents/MacOS/Nextcloud +``` + +Both commands must succeed, and the UUID output must not be empty. + +### Launch the application correctly + +Quit all running instances and launch the tested application by +double-clicking its bundle in Finder. + +Do not start the executable directly from Terminal or SSH. macOS automatically +allows local-network access for command-line tools launched from those +environments and for their child processes. In that situation, +Network.framework can report the connection as ineligible for privacy +enforcement even though the application's Local Network toggle is disabled. +This produces a false-negative test. + +For the same reason, Finder launch is preferred for this regression test over +developer launch mechanisms whose responsible process may affect privacy +attribution. + +### Test procedure + +1. Determine the Mac's Wi-Fi or Ethernet address and subnet. +2. Choose an unused address on that same directly attached subnet. +3. Open **System Settings → Privacy & Security → Local Network**. +4. Disable Local Network access for Nextcloud. +5. Quit Nextcloud, then launch the tested app bundle from Finder. +6. In the account wizard, enter the unused address, for example + `https://192.168.0.64`. +7. Start the connection. + +Expected result: + +- The wizard reports that Local Network access is disabled. +- It does not show the secure-connection recovery dialog. + +Repeat with a VPN active. The expected result is the same. The permission probe +should try the direct local route before a VPN-provided proxy can handle the +connection. + +As a comparison, enable Local Network access and repeat. Because the chosen +address has no server, the result should now be an ordinary timeout or +connection failure rather than the permission message. + +## Troubleshooting + +### The result is a timeout or TLS recovery dialog + +Confirm all of the following: + +- The application was launched from Finder, not by executing its binary in a + shell. +- The tested bundle has a valid signature. +- The running process belongs to the bundle just verified. +- The target address is on a directly attached Wi-Fi or Ethernet subnet. +- The Local Network toggle for the tested application is disabled. + +### Inspect Network.framework activity + +The following command reads the relevant unified logs for a bounded test +interval: + +```sh +/usr/bin/log show \ + --start '2026-07-27 22:20:45' \ + --end '2026-07-27 22:22:35' \ + --style compact --info --debug \ + --predicate 'process == "Nextcloud" AND subsystem BEGINSWITH "com.apple.network"' +``` + +Replace the timestamps with the actual test interval. Useful evidence includes: + +- `prefer no proxy`, confirming that the direct path preference is active; +- `local network denied` or an unsatisfied local-network-denial reason; +- a proxy endpoint such as `127.0.0.1`, showing proxy fallback; +- `Privacy Stance: Not Eligible`, which indicates that the operation was not + subject to normal Local Network privacy enforcement and commonly points to + the launch or identity conditions described above. + +### VPN interpretation + +A VPN can install a system proxy even when the local target remains routed over +Wi-Fi. `prefer_no_proxy` means “try direct first,” not “prohibit all proxies.” +Seeing a later proxy attempt is therefore expected when the direct attempt +fails. What matters for this feature is that macOS has an opportunity to +evaluate the direct local path and report denial before proxy fallback masks +the original condition. diff --git a/doc/macOS-development.md b/doc/macOS-development.md index 1a2ae0cf24092..00a427c58168e 100644 --- a/doc/macOS-development.md +++ b/doc/macOS-development.md @@ -179,5 +179,6 @@ The way Transifex handles Xcode string catalogs creates a high risk of accidenta - **Direct `mac-crafter` CLI usage / branding builds** → [`admin/osx/mac-crafter/README.md`](../admin/osx/mac-crafter/README.md) - **Qt + macOS App Sandbox internals** → [`doc/macOS-Sandbox-Qt.md`](./macOS-Sandbox-Qt.md) +- **Local Network permission diagnostics and testing** → [`doc/local-network-permission.md`](./local-network-permission.md) - **Finder integration (FinderSync) extension — verifying & troubleshooting loading** → [`doc/macOS-FinderSync-extension.md`](./macOS-FinderSync-extension.md) - **NextcloudFileProviderKit Swift package** → [`shell_integration/MacOSX/NextcloudFileProviderKit/README.md`](../shell_integration/MacOSX/NextcloudFileProviderKit/README.md) diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index eb40bf61f527c..4598b49384d62 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -86,6 +86,7 @@ set(client_SRCS conflictsolver.cpp connectionvalidator.h connectionvalidator.cpp + localnetworkpermission.h editlocallyjob.h editlocallyjob.cpp editlocallymanager.h @@ -294,6 +295,8 @@ IF( APPLE ) list(APPEND client_SRCS cocoainitializer_mac.mm) list(APPEND client_SRCS systray_mac_common.mm) list(APPEND client_SRCS notificationsoundplayer_mac.mm) + list(APPEND client_SRCS + macOS/localnetworkpermission.mm) list(APPEND client_SRCS # macOS tray account popup: one type per header/implementation pair. # Shared foundation first, then the base hover view, rows, popups and the @@ -391,7 +394,7 @@ IF( APPLE ) endif() ENDIF() IF( NOT APPLE ) - list(APPEND client_SRCS trayaccountpopup_qt.cpp) + list(APPEND client_SRCS trayaccountpopup_qt.cpp localnetworkpermission.cpp) ENDIF() IF( NOT WIN32 AND NOT APPLE ) @@ -751,7 +754,7 @@ if (APPLE) else() target_link_libraries(nextcloudCore PUBLIC "-framework UserNotifications") endif() - target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation") + target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation" "-framework Network") target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) endif() diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 4369881d89f7e..f2cf90c61c29f 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -18,6 +18,7 @@ #include "userinfo.h" #include "networkjobs.h" #include "clientproxy.h" +#include "localnetworkpermission.h" #include #include "systray.h" @@ -35,6 +36,7 @@ ConnectionValidator::ConnectionValidator(AccountStatePtr accountState, const QSt , _accountState(accountState) , _account(accountState->account()) , _termsOfServiceChecker(_account) + , _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection) { connect(&_termsOfServiceChecker, &TermsOfServiceChecker::done, this, &ConnectionValidator::termsOfServiceCheckDone); @@ -160,25 +162,30 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) return; } + QString error; if (!_account->credentials()->stillValid(reply)) { // Note: Why would this happen on a status.php request? - _errors.append(tr("Authentication error: Either username or password are wrong.")); + error = tr("Authentication error: Either username or password are wrong."); } else { //_errors.append(tr("Unable to connect to %1").arg(_account->url().toString())); - _errors.append(job->errorString()); + error = job->errorString(); } - reportResult(StatusNotFound); + + _localNetworkPermissionCheck(_account->url(), this, [this, error](const bool denied) { + _errors.append(denied ? LocalNetworkPermission::deniedError() : error); + reportResult(StatusNotFound); + }); } void ConnectionValidator::slotJobTimeout(const QUrl &url) { - Q_UNUSED(url); //_errors.append(tr("Unable to connect to %1").arg(url.toString())); - _errors.append(tr("Timeout")); - reportResult(Timeout); + _localNetworkPermissionCheck(url, this, [this](const bool denied) { + _errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout")); + reportResult(Timeout); + }); } - void ConnectionValidator::checkAuthentication() { AbstractCredentials *creds = _account->credentials(); diff --git a/src/gui/connectionvalidator.h b/src/gui/connectionvalidator.h index c58c534eaf066..0fbecebd30013 100644 --- a/src/gui/connectionvalidator.h +++ b/src/gui/connectionvalidator.h @@ -15,6 +15,8 @@ #include "accountfwd.h" #include "clientsideencryption.h" +#include + namespace OCC { /** @@ -157,6 +159,10 @@ protected slots: void termsOfServiceCheckDone(); private: + using LocalNetworkPermissionCheck = std::function)>; + + friend class ConnectionValidatorTestAccess; + #ifndef TOKEN_AUTH_ONLY void reportConnected(); #endif @@ -177,6 +183,7 @@ protected slots: AccountStatePtr _accountState; AccountPtr _account; TermsOfServiceChecker _termsOfServiceChecker; + LocalNetworkPermissionCheck _localNetworkPermissionCheck; bool _isCheckingServerAndAuth = false; }; } diff --git a/src/gui/localnetworkpermission.cpp b/src/gui/localnetworkpermission.cpp new file mode 100644 index 0000000000000..2d1f696d4e456 --- /dev/null +++ b/src/gui/localnetworkpermission.cpp @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "localnetworkpermission.h" + +namespace OCC::LocalNetworkPermission { + +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback) +{ + Q_UNUSED(url) + + if (context) { + callback(false); + } +} + +QString deniedError() +{ + return QCoreApplication::translate("LocalNetworkPermission", + "Local Network access is disabled. Enable it to connect to the server."); +} + +} // namespace OCC::LocalNetworkPermission diff --git a/src/gui/localnetworkpermission.h b/src/gui/localnetworkpermission.h new file mode 100644 index 0000000000000..83d3153d037f6 --- /dev/null +++ b/src/gui/localnetworkpermission.h @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef LOCALNETWORKPERMISSION_H +#define LOCALNETWORKPERMISSION_H + +#include +#include + +#include + +class QObject; + +namespace OCC::LocalNetworkPermission { + +/** + * Checks whether Local Network permission denied a failed connection. + * + * The callback receives false when the platform cannot determine the denial. + */ +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback); + +/** Returns an actionable error message for a denied local network connection. */ +QString deniedError(); + +} // namespace OCC::LocalNetworkPermission + +#endif // LOCALNETWORKPERMISSION_H diff --git a/src/gui/macOS/localnetworkpermission.mm b/src/gui/macOS/localnetworkpermission.mm new file mode 100644 index 0000000000000..dbc4f1b0ef820 --- /dev/null +++ b/src/gui/macOS/localnetworkpermission.mm @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "localnetworkpermission.h" + +#include +#include +#include + +#include + +#include +#include +#include + +namespace { + +void invokeCallback(const QPointer &context, std::function callback, bool denied) +{ + if (!context) { + return; + } + + QMetaObject::invokeMethod(context, [context, callback = std::move(callback), denied] { + if (context) { + callback(denied); + } + }, Qt::QueuedConnection); +} + +bool isLocalNetworkDenied(nw_path_t path) +{ + return path + && nw_path_get_status(path) == nw_path_status_unsatisfied + && nw_path_get_unsatisfied_reason(path) == nw_path_unsatisfied_reason_local_network_denied; +} + +bool checkAvailable() +{ + if (@available(macOS 15.0, *)) { + return true; + } + + return false; +} + +struct ConnectionProbe +{ + nw_connection_t connection = nullptr; + QPointer context; + std::function callback; + bool completed = false; + + void finish(bool denied) + { + if (completed) { + return; + } + + completed = true; + nw_connection_cancel(connection); + nw_release(connection); + connection = nullptr; + invokeCallback(context, std::move(callback), denied); + } + +}; + +} // namespace + +namespace OCC::LocalNetworkPermission { + +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback) +{ + if (!checkAvailable()) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto host = url.host(QUrl::FullyEncoded); + if (!context || host.isEmpty()) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto port = QString::number(url.port(url.scheme() == QStringLiteral("https") ? 443 : 80)); + const auto hostUtf8 = host.toUtf8(); + const auto portUtf8 = port.toUtf8(); + const auto endpoint = nw_endpoint_create_host(hostUtf8.constData(), portUtf8.constData()); + const auto parameters = nw_parameters_create_secure_tcp(NW_PARAMETERS_DISABLE_PROTOCOL, + NW_PARAMETERS_DEFAULT_CONFIGURATION); + if (parameters) { + nw_parameters_set_prefer_no_proxy(parameters, true); + } + if (!endpoint || !parameters) { + if (endpoint) { + nw_release(endpoint); + } + if (parameters) { + nw_release(parameters); + } + invokeCallback(context, std::move(callback), false); + return; + } + + const auto connection = nw_connection_create(endpoint, parameters); + nw_release(endpoint); + nw_release(parameters); + if (!connection) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto probe = std::make_shared(); + probe->connection = connection; + probe->context = context; + probe->callback = std::move(callback); + + const auto queue = dispatch_get_main_queue(); + nw_connection_set_queue(connection, queue); + nw_connection_set_path_changed_handler(connection, ^(nw_path_t path) { + if (isLocalNetworkDenied(path)) { + probe->finish(true); + } + }); + nw_connection_set_state_changed_handler(connection, ^(nw_connection_state_t state, nw_error_t) { + switch (state) { + case nw_connection_state_ready: + probe->finish(false); + break; + case nw_connection_state_waiting: + case nw_connection_state_failed: { + if (probe->completed) { + break; + } + + const auto path = nw_connection_copy_current_path(connection); + const auto denied = isLocalNetworkDenied(path); + if (path) { + nw_release(path); + } + if (denied) { + probe->finish(true); + } + } break; + case nw_connection_state_invalid: + case nw_connection_state_preparing: + case nw_connection_state_cancelled: + break; + } + }); + nw_connection_start(connection); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), queue, ^{ + if (probe->completed) { + return; + } + + const auto path = nw_connection_copy_current_path(connection); + const auto denied = isLocalNetworkDenied(path); + if (path) { + nw_release(path); + } + probe->finish(denied); + }); +} + +QString deniedError() +{ + return QCoreApplication::translate("LocalNetworkPermission", + "Local Network access is disabled. Enable it in System Settings → Privacy & Security → Local Network."); +} + +} // namespace OCC::LocalNetworkPermission diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 1e6f11f4af57a..b35e3ed5b3656 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -18,6 +18,7 @@ #include "folder.h" #include "folderman.h" #include "guiutility.h" +#include "localnetworkpermission.h" #include "networkjobs.h" #include "owncloudpropagator_p.h" #include "selectivesyncdialog.h" @@ -92,6 +93,7 @@ bool localFolderContainsData(const QString &localSyncFolder) AccountWizardController::AccountWizardController(QObject *parent) : QObject(parent) + , _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection) { initialiseAccount(); @@ -879,7 +881,7 @@ void AccountWizardController::slotNoServerFound(QNetworkReply *reply) setErrorText(message); _account->resetRejectedCertificates(); - static_cast(handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply))); + handleFailedServerConnection(_account->url(), checkDowngradeAdvised(reply)); } void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) @@ -887,7 +889,7 @@ void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) setBusy(false); setErrorText(tr("Timeout while trying to connect to %1 at %2.") .arg(Utility::escape(Theme::instance()->appNameGUI()), Utility::escape(url.toString()))); - static_cast(handleSecureConnectionFailure(nullptr, false)); + handleFailedServerConnection(url, false); } void AccountWizardController::slotDetermineAuthType() @@ -1902,16 +1904,31 @@ void AccountWizardController::discardFlow2Auth() setAuthPolling(false); } -bool AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) +void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool retryHttpOnly) +{ + _localNetworkPermissionCheck(url, this, [this, url, retryHttpOnly](bool denied) { + if (_account && _account->url() != url) { + return; + } + + if (denied) { + setErrorText(LocalNetworkPermission::deniedError()); + return; + } + + handleSecureConnectionFailure(nullptr, retryHttpOnly); + }); +} + +void AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) { const auto failedUrl = _account ? _account->url() : reply ? reply->url() : QUrl{}; if (failedUrl.scheme() != "https"_L1 || !_account) { - return false; + return; } _secureConnectionFailedUrl = failedUrl; emit secureConnectionFailed(failedUrl.host(), retryHttpOnly); - return true; } void AccountWizardController::retrySecureConnectionWithoutTls() diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index b9c3ca2be3906..ea6cb3c58bed8 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -14,6 +14,7 @@ #include #include +#include #include #include "accountfwd.h" @@ -256,6 +257,10 @@ private slots: void slotCreateRemoteFolderFinished(QNetworkReply *reply); private: + using LocalNetworkPermissionCheck = std::function)>; + + friend class AccountWizardControllerTestAccess; + void initialiseAccount(); void ensureAccount(); void initialiseOverrideServerChoices(); @@ -298,9 +303,11 @@ private slots: void emitProxySettingsChangedIfNeeded(bool previousValidity, bool previousLocalhostWarning); void discardFlow2Auth(); [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; - [[nodiscard]] bool handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); + void handleFailedServerConnection(const QUrl &url, bool retryHttpOnly); + void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); AccountPtr _account; + LocalNetworkPermissionCheck _localNetworkPermissionCheck; std::unique_ptr _flow2Auth; QPointer _selectiveSyncDialog; enum class ProxyAuthentication { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bbfd290ec6c14..cfa3a89a95d0e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -155,6 +155,7 @@ nextcloud_add_benchmark(LargeSync) nextcloud_add_test(Account) nextcloud_add_test(AccountManager) nextcloud_add_test(AccountWizardController) +nextcloud_add_test(ConnectionValidator) nextcloud_add_test(Folder) nextcloud_add_test(FolderMan) nextcloud_add_test(ForceSyncNow) diff --git a/test/testaccountwizardcontroller.cpp b/test/testaccountwizardcontroller.cpp index 802dfec61d07e..5afa400dc7a06 100644 --- a/test/testaccountwizardcontroller.cpp +++ b/test/testaccountwizardcontroller.cpp @@ -3,8 +3,10 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "gui/wizard/accountwizardcontroller.h" +#include "account.h" #include "configfile.h" +#include "gui/localnetworkpermission.h" +#include "gui/wizard/accountwizardcontroller.h" #include "theme.h" #ifdef BUILD_FILE_PROVIDER_MODULE @@ -19,6 +21,27 @@ using namespace OCC; +namespace OCC { + +class AccountWizardControllerTestAccess +{ +public: + static void setLocalNetworkPermissionDenied(AccountWizardController &controller, bool denied) + { + controller._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) { + callback(denied); + }; + } + + static void setAccountUrl(AccountWizardController &controller, const QUrl &url) + { + controller._account = Account::create(); + controller._account->setUrl(url); + } +}; + +} + class TestAccountWizardController : public QObject { Q_OBJECT @@ -49,6 +72,53 @@ private slots: QStringLiteral("http://cloud.example")); } + void localNetworkPermissionCheckIgnoresInvalidUrls() + { + auto callbackCalled = false; + auto denied = true; + + LocalNetworkPermission::checkDeniedForConnection({}, this, [&callbackCalled, &denied](bool result) { + callbackCalled = true; + denied = result; + }); + + QTRY_VERIFY(callbackCalled); + QVERIFY(!denied); + } + + void localNetworkPermissionFailureSkipsSecureConnectionRecovery() + { + AccountWizardController controller; + AccountWizardControllerTestAccess::setLocalNetworkPermissionDenied(controller, true); + QSignalSpy recoverySpy(&controller, &AccountWizardController::secureConnectionFailed); + + QVERIFY(QMetaObject::invokeMethod(&controller, + "slotNoServerFoundTimeout", + Qt::DirectConnection, + Q_ARG(QUrl, QUrl(QStringLiteral("https://cloud.example"))))); + + QCOMPARE(recoverySpy.count(), 0); + QCOMPARE(controller.errorText(), LocalNetworkPermission::deniedError()); + } + + void otherServerFailureOffersSecureConnectionRecovery() + { + AccountWizardController controller; + const auto serverUrl = QUrl(QStringLiteral("https://cloud.example")); + AccountWizardControllerTestAccess::setLocalNetworkPermissionDenied(controller, false); + AccountWizardControllerTestAccess::setAccountUrl(controller, serverUrl); + QSignalSpy recoverySpy(&controller, &AccountWizardController::secureConnectionFailed); + + QVERIFY(QMetaObject::invokeMethod(&controller, + "slotNoServerFoundTimeout", + Qt::DirectConnection, + Q_ARG(QUrl, serverUrl))); + + QCOMPARE(recoverySpy.count(), 1); + const auto arguments = recoverySpy.takeFirst(); + QCOMPARE(arguments.at(0).toString(), serverUrl.host()); + QCOMPARE(arguments.at(1).toBool(), false); + } void invalidServerUrlStaysOnServerStep() { QFETCH(QString, serverUrl); diff --git a/test/testconnectionvalidator.cpp b/test/testconnectionvalidator.cpp new file mode 100644 index 0000000000000..a48c69afe31b6 --- /dev/null +++ b/test/testconnectionvalidator.cpp @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "account.h" +#include "gui/connectionvalidator.h" +#include "gui/localnetworkpermission.h" +#include "testhelper.h" + +#include +#include +#include + +using namespace OCC; + +namespace OCC { + +class ConnectionValidatorTestAccess +{ +public: + static void setLocalNetworkPermissionDenied(ConnectionValidator &validator, bool denied) + { + validator._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) { + callback(denied); + }; + } + + static void reportTimeout(ConnectionValidator &validator, const QUrl &url) + { + validator.slotJobTimeout(url); + } +}; + +} + +class TestConnectionValidator : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + } + + void localNetworkPermissionFailureReplacesTimeout() + { + const auto account = Account::create(); + account->setUrl(QUrl(QStringLiteral("https://cloud.example"))); + const auto accountState = AccountStatePtr(new FakeAccountState(account)); + ConnectionValidator validator(accountState, {}); + ConnectionValidatorTestAccess::setLocalNetworkPermissionDenied(validator, true); + QSignalSpy resultSpy(&validator, &ConnectionValidator::connectionResult); + + ConnectionValidatorTestAccess::reportTimeout(validator, account->url()); + + QCOMPARE(resultSpy.count(), 1); + const auto result = resultSpy.takeFirst(); + QCOMPARE(result.at(0).value(), ConnectionValidator::Timeout); + QCOMPARE(result.at(1).toStringList(), QStringList({LocalNetworkPermission::deniedError()})); + } +}; + +QTEST_MAIN(TestConnectionValidator) +#include "testconnectionvalidator.moc"