From 78f00acaa2683cf983738c3194503ad40b9df689 Mon Sep 17 00:00:00 2001 From: Felix Weilbach Date: Tue, 12 Jan 2021 09:23:41 +0100 Subject: [PATCH] Add push notifications for file changes Resolves #2802 Signed-off-by: Felix Weilbach --- src/gui/folderman.cpp | 133 +++++++++-- src/gui/folderman.h | 9 + src/libsync/CMakeLists.txt | 3 + src/libsync/account.cpp | 35 +++ src/libsync/account.h | 9 + src/libsync/capabilities.cpp | 23 ++ src/libsync/capabilities.h | 13 ++ src/libsync/creds/abstractcredentials.h | 1 + src/libsync/creds/dummycredentials.cpp | 6 + src/libsync/creds/dummycredentials.h | 1 + src/libsync/creds/httpcredentials.h | 2 +- src/libsync/pushnotifications.cpp | 189 ++++++++++++++++ src/libsync/pushnotifications.h | 113 ++++++++++ test/CMakeLists.txt | 2 + test/pushnotificationstestutils.cpp | 135 ++++++++++++ test/pushnotificationstestutils.h | 73 +++++++ test/syncenginetestutils.h | 1 + test/testcapabilities.cpp | 72 ++++++ test/testpushnotifications.cpp | 279 ++++++++++++++++++++++++ 19 files changed, 1076 insertions(+), 23 deletions(-) create mode 100644 src/libsync/pushnotifications.cpp create mode 100644 src/libsync/pushnotifications.h create mode 100644 test/pushnotificationstestutils.cpp create mode 100644 test/pushnotificationstestutils.h create mode 100644 test/testcapabilities.cpp create mode 100644 test/testpushnotifications.cpp diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index 1e48367a1..c61fafa0b 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -24,6 +24,7 @@ #include "filesystem.h" #include "lockwatcher.h" #include "common/asserts.h" +#include #include #ifdef Q_OS_MAC @@ -77,6 +78,8 @@ FolderMan::FolderMan(QObject *parent) connect(_lockWatcher.data(), &LockWatcher::fileUnlocked, this, &FolderMan::slotWatchedFileUnlocked); + + connect(this, &FolderMan::folderListChanged, this, &FolderMan::slotSetupPushNotifications); } FolderMan *FolderMan::instance() @@ -823,34 +826,82 @@ void FolderMan::slotStartScheduledFolderSync() } } +bool FolderMan::pushNotificationsFilesReady(Account *account) +{ + const auto pushNotifications = account->pushNotifications(); + const auto pushFilesAvailable = account->capabilities().availablePushNotifications() & PushNotificationType::Files; + + return pushFilesAvailable && pushNotifications && pushNotifications->isReady(); +} + void FolderMan::slotEtagPollTimerTimeout() { - ConfigFile cfg; - auto polltime = cfg.remotePollInterval(); + qCInfo(lcFolderMan) << "Etag poll timer timeout"; - for (Folder *f : qAsConst(_folderMap)) { - if (!f) { - continue; - } - if (f->isSyncRunning()) { - continue; - } - if (_scheduledFolders.contains(f)) { - continue; - } - if (_disabledFolders.contains(f)) { - continue; - } - if (f->etagJob() || f->isBusy() || !f->canSync()) { - continue; - } - if (f->msecSinceLastSync() < polltime) { - continue; - } - QMetaObject::invokeMethod(f, "slotRunEtagJob", Qt::QueuedConnection); + const auto folderMapValues = _folderMap.values(); + + qCInfo(lcFolderMan) << "Folders to sync:" << folderMapValues.size(); + + QList foldersToRun; + + // Some folders need not to be checked because they use the push notifications + std::copy_if(folderMapValues.begin(), folderMapValues.end(), std::back_inserter(foldersToRun), [this](Folder *folder) -> bool { + const auto account = folder->accountState()->account(); + const auto capabilities = account->capabilities(); + const auto pushNotifications = account->pushNotifications(); + + return !pushNotificationsFilesReady(account.data()); + }); + + qCInfo(lcFolderMan) << "Number of folders that don't use push notifications:" << foldersToRun.size(); + + runEtagJobsIfPossible(foldersToRun); +} + +void FolderMan::runEtagJobsIfPossible(const QList &folderMap) +{ + for (auto folder : folderMap) { + runEtagJobIfPossible(folder); } } +void FolderMan::runEtagJobIfPossible(Folder *folder) +{ + const ConfigFile cfg; + const auto polltime = cfg.remotePollInterval(); + + qCInfo(lcFolderMan) << "Run etag job on folder" << folder; + + if (!folder) { + return; + } + if (folder->isSyncRunning()) { + qCInfo(lcFolderMan) << "Can not run etag job: Sync is running"; + return; + } + if (_scheduledFolders.contains(folder)) { + qCInfo(lcFolderMan) << "Can not run etag job: Folder is alreday scheduled"; + return; + } + if (_disabledFolders.contains(folder)) { + qCInfo(lcFolderMan) << "Can not run etag job: Folder is disabled"; + return; + } + if (folder->etagJob() || folder->isBusy() || !folder->canSync()) { + qCInfo(lcFolderMan) << "Can not run etag job: Folder is busy"; + return; + } + // When not using push notifications, make sure polltime is reached + if (!pushNotificationsFilesReady(folder->accountState()->account().data())) { + if (folder->msecSinceLastSync() < polltime) { + qCInfo(lcFolderMan) << "Can not run etag job: Polltime not reached"; + return; + } + } + + QMetaObject::invokeMethod(folder, "slotRunEtagJob", Qt::QueuedConnection); +} + void FolderMan::slotRemoveFoldersForAccount(AccountState *accountState) { QVarLengthArray foldersToRemove; @@ -1631,4 +1682,42 @@ void FolderMan::restartApplication() } } +void FolderMan::slotSetupPushNotifications(const Folder::Map &folderMap) +{ + for (auto folder : folderMap) { + const auto account = folder->accountState()->account(); + + // See if the account already provides the PushNotifications object and if yes connect to it. + // If we can't connect at this point, the signals will be connected in slotPushNotificationsReady() + // after the PushNotification object emitted the ready signal + slotConnectToPushNotifications(account.data()); + connect(account.data(), &Account::pushNotificationsReady, this, &FolderMan::slotConnectToPushNotifications, Qt::UniqueConnection); + } +} + +void FolderMan::slotProcessFilesPushNotification(Account *account) +{ + qCInfo(lcFolderMan) << "Got files push notification for account" << account; + + for (auto folder : _folderMap) { + // Just run on the folders that belong to this account + if (folder->accountState()->account() != account) { + continue; + } + + qCInfo(lcFolderMan) << "Schedule folder" << folder << "for sync"; + scheduleFolder(folder); + } +} + +void FolderMan::slotConnectToPushNotifications(Account *account) +{ + const auto pushNotifications = account->pushNotifications(); + + if (pushNotificationsFilesReady(account)) { + qCInfo(lcFolderMan) << "Push notifications ready"; + connect(pushNotifications, &PushNotifications::filesChanged, this, &FolderMan::slotProcessFilesPushNotification, Qt::UniqueConnection); + } +} + } // namespace OCC diff --git a/src/gui/folderman.h b/src/gui/folderman.h index 7c72eb953..1a6134fa4 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -288,6 +288,10 @@ private slots: */ void slotScheduleFolderByTime(); + void slotSetupPushNotifications(const Folder::Map &); + void slotProcessFilesPushNotification(Account *account); + void slotConnectToPushNotifications(Account *account); + private: /** Adds a new folder, does not add it to the account settings and * does not set an account on the new folder. @@ -313,6 +317,11 @@ private: void setupFoldersHelper(QSettings &settings, AccountStatePtr account, const QStringList &ignoreKeys, bool backwardsCompatible, bool foldersWithPlaceholders); + void runEtagJobsIfPossible(const QList &folderMap); + void runEtagJobIfPossible(Folder *folder); + + bool pushNotificationsFilesReady(Account *account); + QSet _disabledFolders; Folder::Map _folderMap; QString _folderConfigPath; diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 292ba8afb..c63201dcc 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -19,6 +19,7 @@ ENDIF(${CMAKE_SYSTEM_NAME} MATCHES "FreeBSD|NetBSD|OpenBSD") set(libsync_SRCS account.cpp + pushnotifications.cpp wordlist.cpp bandwidthmanager.cpp capabilities.cpp @@ -116,6 +117,7 @@ IF (NOT APPLE) ) ENDIF(NOT APPLE) +find_package(Qt5 REQUIRED COMPONENTS WebSockets) add_library(${synclib_NAME} SHARED ${libsync_SRCS}) target_link_libraries(${synclib_NAME} "${csync_NAME}" @@ -123,6 +125,7 @@ target_link_libraries(${synclib_NAME} OpenSSL::SSL ${OS_SPECIFIC_LINK_LIBRARIES} Qt5::Core Qt5::Network + Qt5::WebSockets ) if (NOT TOKEN_AUTH_ONLY) diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index bdb0cf342..df8e13ec0 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -20,6 +20,7 @@ #include "creds/abstractcredentials.h" #include "capabilities.h" #include "theme.h" +#include "pushnotifications.h" #include "common/asserts.h" #include "clientsideencryption.h" @@ -56,6 +57,7 @@ Account::Account(QObject *parent) , _davPath(Theme::instance()->webDavPath()) { qRegisterMetaType("AccountPtr"); + qRegisterMetaType("Account*"); } AccountPtr Account::create() @@ -201,6 +203,32 @@ void Account::setCredentials(AbstractCredentials *cred) this, &Account::slotCredentialsFetched); connect(_credentials.data(), &AbstractCredentials::asked, this, &Account::slotCredentialsAsked); + + trySetupPushNotifications(); +} + +void Account::trySetupPushNotifications() +{ + if (_capabilities.availablePushNotifications() != PushNotificationType::None) { + qCInfo(lcAccount) << "Try to setup push notifications"; + + if (!_pushNotifications) { + _pushNotifications = new PushNotifications(this, this); + + connect(_pushNotifications, &PushNotifications::ready, this, [this]() { emit pushNotificationsReady(this); }); + + const auto deletePushNotifications = [this]() { + qCInfo(lcAccount) << "Delete push notifications object because authentication failed or connection lost"; + _pushNotifications->deleteLater(); + _pushNotifications = nullptr; + }; + + connect(_pushNotifications, &PushNotifications::connectionLost, this, deletePushNotifications); + connect(_pushNotifications, &PushNotifications::authenticationFailed, this, deletePushNotifications); + } + // If push notifications already running it is no problem to call setup again + _pushNotifications->setup(); + } } QUrl Account::davUrl() const @@ -478,6 +506,8 @@ const Capabilities &Account::capabilities() const void Account::setCapabilities(const QVariantMap &caps) { _capabilities = Capabilities(caps); + + trySetupPushNotifications(); } QString Account::serverVersion() const @@ -661,4 +691,9 @@ void Account::slotDirectEditingRecieved(const QJsonDocument &json) } } +PushNotifications *Account::pushNotifications() const +{ + return _pushNotifications; +} + } // namespace OCC diff --git a/src/libsync/account.h b/src/libsync/account.h index 8b6ebe8fb..594bfce6e 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -54,6 +54,7 @@ class Account; using AccountPtr = QSharedPointer; class AccessManager; class SimpleNetworkJob; +class PushNotifications; /** * @brief Reimplement this to handle SSL errors from libsync @@ -250,6 +251,8 @@ public: // Check for the directEditing capability void fetchDirectEditors(const QUrl &directEditingURL, const QString &directEditingETag); + PushNotifications *pushNotifications() const; + public slots: /// Used when forgetting credentials void clearQNAMCache(); @@ -279,6 +282,8 @@ signals: /// Used in RemoteWipe void appPasswordRetrieved(QString); + void pushNotificationsReady(Account *account); + protected Q_SLOTS: void slotCredentialsFetched(); void slotCredentialsAsked(); @@ -287,6 +292,7 @@ protected Q_SLOTS: private: Account(QObject *parent = nullptr); void setSharedThis(AccountPtr sharedThis); + void trySetupPushNotifications(); QWeakPointer _sharedThis; QString _id; @@ -331,6 +337,8 @@ private: // Direct Editing QString _lastDirectEditingETag; + PushNotifications *_pushNotifications = nullptr; + /* IMPORTANT - remove later - FIXME MS@2019-12-07 --> * TODO: For "Log out" & "Remove account": Remove client CA certs and KEY! * @@ -350,5 +358,6 @@ private: } Q_DECLARE_METATYPE(OCC::AccountPtr) +Q_DECLARE_METATYPE(OCC::Account *) #endif //SERVERCONNECTION_H diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index 04600601d..73d5f4b4d 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -16,6 +16,7 @@ #include #include +#include #include @@ -176,6 +177,28 @@ bool Capabilities::chunkingNg() const return _capabilities["dav"].toMap()["chunking"].toByteArray() >= "1.0"; } +PushNotificationTypes Capabilities::availablePushNotifications() const +{ + if (!_capabilities.contains("notify_push")) { + return PushNotificationType::None; + } + + const auto types = _capabilities["notify_push"].toMap()["type"].toStringList(); + PushNotificationTypes pushNotificationTypes; + + if (types.contains("files")) { + pushNotificationTypes.setFlag(PushNotificationType::Files); + } + + return pushNotificationTypes; +} + +QUrl Capabilities::pushNotificationsWebSocketUrl() const +{ + const auto websocket = _capabilities["notify_push"].toMap()["endpoints"].toMap()["websocket"].toString(); + return QUrl(websocket); +} + bool Capabilities::chunkingParallelUploadDisabled() const { return _capabilities["dav"].toMap()["chunkingParallelUploadDisabled"].toBool(); diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index e64f68d0b..c1d237024 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -26,6 +26,13 @@ namespace OCC { class DirectEditor; +enum PushNotificationType { + None = 0, + Files = 1 +}; +Q_DECLARE_FLAGS(PushNotificationTypes, PushNotificationType) +Q_DECLARE_OPERATORS_FOR_FLAGS(PushNotificationTypes) + /** * @brief The Capabilities class represents the capabilities of an ownCloud * server @@ -48,6 +55,12 @@ public: bool shareResharing() const; bool chunkingNg() const; + /// Returns which kind of push notfications are available + PushNotificationTypes availablePushNotifications() const; + + /// Websocket url for files push notifications if available + QUrl pushNotificationsWebSocketUrl() const; + /// disable parallel upload in chunking bool chunkingParallelUploadDisabled() const; diff --git a/src/libsync/creds/abstractcredentials.h b/src/libsync/creds/abstractcredentials.h index d679ac6be..720caf0d2 100644 --- a/src/libsync/creds/abstractcredentials.h +++ b/src/libsync/creds/abstractcredentials.h @@ -45,6 +45,7 @@ public: virtual QString authType() const = 0; virtual QString user() const = 0; + virtual QString password() const = 0; virtual QNetworkAccessManager *createQNAM() const = 0; /** Whether there are credentials that can be used for a connection attempt. */ diff --git a/src/libsync/creds/dummycredentials.cpp b/src/libsync/creds/dummycredentials.cpp index 3c5609d35..caa163854 100644 --- a/src/libsync/creds/dummycredentials.cpp +++ b/src/libsync/creds/dummycredentials.cpp @@ -27,6 +27,12 @@ QString DummyCredentials::user() const return _user; } +QString DummyCredentials::password() const +{ + Q_UNREACHABLE(); + return QString(); +} + QNetworkAccessManager *DummyCredentials::createQNAM() const { return new AccessManager; diff --git a/src/libsync/creds/dummycredentials.h b/src/libsync/creds/dummycredentials.h index f9fc11dc5..80aa04109 100644 --- a/src/libsync/creds/dummycredentials.h +++ b/src/libsync/creds/dummycredentials.h @@ -28,6 +28,7 @@ public: QString _password; QString authType() const override; QString user() const override; + QString password() const override; QNetworkAccessManager *createQNAM() const override; bool ready() const override; bool stillValid(QNetworkReply *reply) override; diff --git a/src/libsync/creds/httpcredentials.h b/src/libsync/creds/httpcredentials.h index 04c7efc9a..7bc9e02e1 100644 --- a/src/libsync/creds/httpcredentials.h +++ b/src/libsync/creds/httpcredentials.h @@ -91,7 +91,7 @@ public: void persist() override; QString user() const override; // the password or token - QString password() const; + QString password() const override; void invalidateToken() override; void forgetSensitiveData() override; QString fetchUser(); diff --git a/src/libsync/pushnotifications.cpp b/src/libsync/pushnotifications.cpp new file mode 100644 index 000000000..3aae93ef1 --- /dev/null +++ b/src/libsync/pushnotifications.cpp @@ -0,0 +1,189 @@ +#include "pushnotifications.h" +#include "creds/abstractcredentials.h" +#include "account.h" + +namespace { +static constexpr int MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS = 3; +} + +namespace OCC { + +Q_LOGGING_CATEGORY(lcPushNotifications, "nextcloud.sync.pushnotifications", QtInfoMsg) + +PushNotifications::PushNotifications(Account *account, QObject *parent) + : QObject(parent) + , _account(account) +{ +} + +PushNotifications::~PushNotifications() +{ + closeWebSocket(); +} + +void PushNotifications::setup() +{ + _isReady = false; + _failedAuthenticationAttemptsCount = 0; + reconnectToWebSocket(); +} + +void PushNotifications::reconnectToWebSocket() +{ + closeWebSocket(); + openWebSocket(); +} + +void PushNotifications::closeWebSocket() +{ + if (_webSocket) { + qCInfo(lcPushNotifications) << "Close websocket"; + _webSocket->close(); + } +} + +void PushNotifications::onWebSocketConnected() +{ + qCInfo(lcPushNotifications) << "Connected to websocket"; + + connect(_webSocket, &QWebSocket::textMessageReceived, this, &PushNotifications::onWebSocketTextMessageReceived, Qt::UniqueConnection); + + authenticateOnWebSocket(); +} + +void PushNotifications::authenticateOnWebSocket() +{ + const auto credentials = _account->credentials(); + const auto username = credentials->user(); + const auto password = credentials->password(); + + // Authenticate + _webSocket->sendTextMessage(username); + _webSocket->sendTextMessage(password); +} + +void PushNotifications::onWebSocketDisconnected() +{ + qCInfo(lcPushNotifications) << "Disconnected from websocket"; +} + +void PushNotifications::onWebSocketTextMessageReceived(const QString &message) +{ + qCInfo(lcPushNotifications) << "Received push notification:" << message; + + if (message == "notify_file") { + handleNotifyFile(); + } else if (message == "notify_activity" || message == "notify_notification") { + handleNotification(); + } else if (message == "authenticated") { + handleAuthenticated(); + } else if (message == "err: Invalid credentials") { + handleInvalidCredentials(); + } +} + +void PushNotifications::onWebSocketError(QAbstractSocket::SocketError error) +{ + // This error gets thrown in testSetup_maxConnectionAttemptsReached_deletePushNotifications after + // the second connection attempt. I have no idea why this happens. Maybe the socket gets not closed correctly? + // I think it's fine to ignore this error. + if (error == QAbstractSocket::UnfinishedSocketOperationError) { + return; + } + + qCWarning(lcPushNotifications) << "Websocket error" << error; + + _isReady = false; + emit connectionLost(); +} + +bool PushNotifications::tryReconnectToWebSocket() +{ + ++_failedAuthenticationAttemptsCount; + if (_failedAuthenticationAttemptsCount >= MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS) { + qCInfo(lcPushNotifications) << "Max authentication attempts reached"; + return false; + } + + if (!_reconnectTimer) { + _reconnectTimer = new QTimer(this); + } + + _reconnectTimer->setInterval(_reconnectTimerInterval); + _reconnectTimer->setSingleShot(true); + connect(_reconnectTimer, &QTimer::timeout, [this]() { + reconnectToWebSocket(); + }); + _reconnectTimer->start(); + + return true; +} + +void PushNotifications::onWebSocketSslErrors(const QList &errors) +{ + qCWarning(lcPushNotifications) << "Received websocket ssl errors:" << errors; + _isReady = false; + emit authenticationFailed(); +} + +void PushNotifications::openWebSocket() +{ + // Open websocket + const auto capabilities = _account->capabilities(); + const auto webSocketUrl = capabilities.pushNotificationsWebSocketUrl(); + + if (!_webSocket) { + qCInfo(lcPushNotifications) << "Create websocket"; + _webSocket = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this); + } + + if (_webSocket) { + connect(_webSocket, QOverload::of(&QWebSocket::error), this, &PushNotifications::onWebSocketError, Qt::UniqueConnection); + connect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors, Qt::UniqueConnection); + connect(_webSocket, &QWebSocket::connected, this, &PushNotifications::onWebSocketConnected, Qt::UniqueConnection); + connect(_webSocket, &QWebSocket::disconnected, this, &PushNotifications::onWebSocketDisconnected, Qt::UniqueConnection); + + qCInfo(lcPushNotifications) << "Open connection to websocket on:" << webSocketUrl; + _webSocket->open(webSocketUrl); + } +} + +void PushNotifications::setReconnectTimerInterval(uint32_t interval) +{ + _reconnectTimerInterval = interval; +} + +bool PushNotifications::isReady() const +{ + return _isReady; +} + +void PushNotifications::handleAuthenticated() +{ + qCInfo(lcPushNotifications) << "Authenticated successful on websocket"; + _failedAuthenticationAttemptsCount = 0; + _isReady = true; + emit ready(); +} + +void PushNotifications::handleNotifyFile() +{ + qCInfo(lcPushNotifications) << "Files push notification arrived"; + emit filesChanged(_account); +} + +void PushNotifications::handleInvalidCredentials() +{ + qCInfo(lcPushNotifications) << "Invalid credentials submitted to websocket"; + if (!tryReconnectToWebSocket()) { + _isReady = false; + emit authenticationFailed(); + } +} + +void PushNotifications::handleNotification() +{ + qCInfo(lcPushNotifications) << "Notification or activity push notification arrived"; + emit notification(_account); +} +} diff --git a/src/libsync/pushnotifications.h b/src/libsync/pushnotifications.h new file mode 100644 index 000000000..3c7e0e02c --- /dev/null +++ b/src/libsync/pushnotifications.h @@ -0,0 +1,113 @@ +/* + * Copyright (C) by Felix Weilbach + * + * 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 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#pragma once + +#include +#include + +#include "capabilities.h" + +namespace OCC { + +class Account; +class AbstractCredentials; + +class OWNCLOUDSYNC_EXPORT PushNotifications : public QObject +{ + Q_OBJECT + +public: + explicit PushNotifications(Account *account, QObject *parent = nullptr); + + ~PushNotifications(); + + /** + * Setup push notifications + * + * This method needs to be called before push notifications can be used. + */ + void setup(); + + /** + * Set the interval for reconnection attempts + */ + void setReconnectTimerInterval(uint32_t interval); + + /** + * Indicates if push notifications ready to use + * + * Ready to use means connected and authenticated. + */ + bool isReady() const; + +signals: + /** + * Will be emitted after a successful connection and authentication + */ + void ready(); + + /** + * Will be emitted if files on the server changed + */ + void filesChanged(Account *account); + + /** + * Will be emitted if there is a new notification or activity on the server + */ + void notification(Account *account); + + /** + * Will be emitted if push notifications are unable to authenticate + * + * It's save to call #PushNotifications::setup() after this signal has been emitted. + */ + void authenticationFailed(); + + /** + * Will be emitted if push notifications are unable to connect or the connection timed out + * + * It's save to call #PushNotifications::setup() after this signal has been emitted. + */ + void connectionLost(); + +private slots: + void onWebSocketConnected(); + void onWebSocketDisconnected(); + void onWebSocketTextMessageReceived(const QString &message); + void onWebSocketError(QAbstractSocket::SocketError error); + void onWebSocketSslErrors(const QList &errors); + +private: + void openWebSocket(); + void reconnectToWebSocket(); + void closeWebSocket(); + void authenticateOnWebSocket(); + bool tryReconnectToWebSocket(); + void initReconnectTimer(); + + void handleAuthenticated(); + void handleNotifyFile(); + void handleInvalidCredentials(); + void handleNotification(); + + Account *_account = nullptr; + QWebSocket *_webSocket = nullptr; + uint8_t _failedAuthenticationAttemptsCount = 0; + QTimer *_reconnectTimer = nullptr; + uint32_t _reconnectTimerInterval = 20 * 1000; + bool _isReady = false; +}; + +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9635af950..221dca173 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -69,6 +69,8 @@ nextcloud_add_test(SelectiveSync "") nextcloud_add_test(DatabaseError "") nextcloud_add_test(LockedFiles "../src/gui/lockwatcher.cpp") nextcloud_add_test(FolderWatcher "${FolderWatcher_SRC}") +nextcloud_add_test(Capabilities "") +nextcloud_add_test(PushNotifications "pushnotificationstestutils.cpp") if( UNIX AND NOT APPLE ) nextcloud_add_test(InotifyWatcher "${FolderWatcher_SRC}") diff --git a/test/pushnotificationstestutils.cpp b/test/pushnotificationstestutils.cpp new file mode 100644 index 000000000..fc787fae2 --- /dev/null +++ b/test/pushnotificationstestutils.cpp @@ -0,0 +1,135 @@ +#include +#include +#include + +#include "pushnotificationstestutils.h" + +Q_LOGGING_CATEGORY(lcFakeWebSocketServer, "nextcloud.test.fakewebserver", QtInfoMsg) + +FakeWebSocketServer::FakeWebSocketServer(quint16 port, QObject *parent) + : QObject(parent) + , _webSocketServer(new QWebSocketServer(QStringLiteral("Fake Server"), QWebSocketServer::NonSecureMode, this)) +{ + if (_webSocketServer->listen(QHostAddress::Any, port)) { + connect(_webSocketServer, &QWebSocketServer::newConnection, this, &FakeWebSocketServer::onNewConnection); + connect(_webSocketServer, &QWebSocketServer::closed, this, &FakeWebSocketServer::closed); + qCInfo(lcFakeWebSocketServer) << "Open fake websocket server on port:" << port; + return; + } + Q_UNREACHABLE(); +} + +FakeWebSocketServer::~FakeWebSocketServer() +{ + close(); +} + +void FakeWebSocketServer::close() +{ + if (_webSocketServer->isListening()) { + qCInfo(lcFakeWebSocketServer) << "Close fake websocket server"; + + _webSocketServer->close(); + qDeleteAll(_clients.begin(), _clients.end()); + } +} + +void FakeWebSocketServer::processTextMessageInternal(const QString &message) +{ + auto client = qobject_cast(sender()); + emit processTextMessage(client, message); +} + +void FakeWebSocketServer::onNewConnection() +{ + qCInfo(lcFakeWebSocketServer) << "New connection on fake websocket server"; + + auto socket = _webSocketServer->nextPendingConnection(); + + connect(socket, &QWebSocket::textMessageReceived, this, &FakeWebSocketServer::processTextMessageInternal); + connect(socket, &QWebSocket::disconnected, this, &FakeWebSocketServer::socketDisconnected); + + _clients << socket; +} + +void FakeWebSocketServer::socketDisconnected() +{ + qCInfo(lcFakeWebSocketServer) << "Socket disconnected"; + + auto client = qobject_cast(sender()); + + if (client) { + _clients.removeAll(client); + client->deleteLater(); + } +} + +OCC::AccountPtr FakeWebSocketServer::createAccount() +{ + auto account = OCC::Account::create(); + + QStringList typeList; + typeList.append("files"); + + QString websocketUrl("ws://localhost:12345"); + + QVariantMap endpointsMap; + endpointsMap["websocket"] = websocketUrl; + + QVariantMap notifyPushMap; + notifyPushMap["type"] = typeList; + notifyPushMap["endpoints"] = endpointsMap; + + QVariantMap capabilitiesMap; + capabilitiesMap["notify_push"] = notifyPushMap; + + account->setCapabilities(capabilitiesMap); + + return account; +} + +CredentialsStub::CredentialsStub(const QString &user, const QString &password) + : _user(user) + , _password(password) +{ +} + +QString CredentialsStub::authType() const +{ + return ""; +} + +QString CredentialsStub::user() const +{ + return _user; +} + +QString CredentialsStub::password() const +{ + return _password; +} + +QNetworkAccessManager *CredentialsStub::createQNAM() const +{ + return nullptr; +} + +bool CredentialsStub::ready() const +{ + return false; +} + +void CredentialsStub::fetchFromKeychain() { } + +void CredentialsStub::askFromUser() { } + +bool CredentialsStub::stillValid(QNetworkReply * /*reply*/) +{ + return false; +} + +void CredentialsStub::persist() { } + +void CredentialsStub::invalidateToken() { } + +void CredentialsStub::forgetSensitiveData() { } diff --git a/test/pushnotificationstestutils.h b/test/pushnotificationstestutils.h new file mode 100644 index 000000000..c6b5b7d3b --- /dev/null +++ b/test/pushnotificationstestutils.h @@ -0,0 +1,73 @@ +/* + * Copyright (C) by Felix Weilbach + * + * 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 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#pragma once + +#include + +#include +#include + +#include "creds/abstractcredentials.h" +#include "account.h" + +class FakeWebSocketServer : public QObject +{ + Q_OBJECT +public: + explicit FakeWebSocketServer(quint16 port = 12345, QObject *parent = nullptr); + + ~FakeWebSocketServer(); + + void close(); + + static OCC::AccountPtr createAccount(); + +signals: + void closed(); + void processTextMessage(QWebSocket *sender, const QString &message); + +private slots: + void processTextMessageInternal(const QString &message); + void onNewConnection(); + void socketDisconnected(); + +private: + QWebSocketServer *_webSocketServer; + QList _clients; +}; + +class CredentialsStub : public OCC::AbstractCredentials +{ + Q_OBJECT + +public: + CredentialsStub(const QString &user, const QString &password); + virtual QString authType() const; + virtual QString user() const; + virtual QString password() const; + virtual QNetworkAccessManager *createQNAM() const; + virtual bool ready() const; + virtual void fetchFromKeychain(); + virtual void askFromUser(); + + virtual bool stillValid(QNetworkReply *reply); + virtual void persist(); + virtual void invalidateToken(); + virtual void forgetSensitiveData(); + +private: + QString _user; + QString _password; +}; diff --git a/test/syncenginetestutils.h b/test/syncenginetestutils.h index 1dc34c540..8ef726c4e 100644 --- a/test/syncenginetestutils.h +++ b/test/syncenginetestutils.h @@ -413,6 +413,7 @@ public: FakeCredentials(QNetworkAccessManager *qnam) : _qnam{qnam} { } virtual QString authType() const { return "test"; } virtual QString user() const { return "admin"; } + virtual QString password() const { return "password"; } virtual QNetworkAccessManager *createQNAM() const { return _qnam; } virtual bool ready() const { return true; } virtual void fetchFromKeychain() { } diff --git a/test/testcapabilities.cpp b/test/testcapabilities.cpp new file mode 100644 index 000000000..13670e98a --- /dev/null +++ b/test/testcapabilities.cpp @@ -0,0 +1,72 @@ +#include + +#include "capabilities.h" + +class TestCapabilities : public QObject +{ + Q_OBJECT + +private slots: + void testPushNotificationsAvailable_pushNotificationsForFilesAvailable_returnTrue() + { + QStringList typeList; + typeList.append("files"); + + QVariantMap notifyPushMap; + notifyPushMap["type"] = typeList; + + QVariantMap capabilitiesMap; + capabilitiesMap["notify_push"] = notifyPushMap; + + const auto &capabilities = OCC::Capabilities(capabilitiesMap); + const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files); + + QCOMPARE(filesPushNotificationsAvailable, true); + } + + void testPushNotificationsAvailable_pushNotificationsForFilesNotAvailable_returnFalse() + { + QStringList typeList; + typeList.append("nofiles"); + + QVariantMap notifyPushMap; + notifyPushMap["type"] = typeList; + + QVariantMap capabilitiesMap; + capabilitiesMap["notify_push"] = notifyPushMap; + + const auto &capabilities = OCC::Capabilities(capabilitiesMap); + const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files); + + QCOMPARE(filesPushNotificationsAvailable, false); + } + + void testPushNotificationsAvailable_pushNotificationsNotAvailable_returnFalse() + { + const auto &capabilities = OCC::Capabilities(QVariantMap()); + const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files); + + QCOMPARE(filesPushNotificationsAvailable, false); + } + + void testPushNotificationsWebSocketUrl_urlAvailable_returnUrl() + { + QString websocketUrl("testurl"); + + QVariantMap endpointsMap; + endpointsMap["websocket"] = websocketUrl; + + QVariantMap notifyPushMap; + notifyPushMap["endpoints"] = endpointsMap; + + QVariantMap capabilitiesMap; + capabilitiesMap["notify_push"] = notifyPushMap; + + const auto &capabilities = OCC::Capabilities(capabilitiesMap); + + QCOMPARE(capabilities.pushNotificationsWebSocketUrl(), websocketUrl); + } +}; + +QTEST_GUILESS_MAIN(TestCapabilities) +#include "testcapabilities.moc" diff --git a/test/testpushnotifications.cpp b/test/testpushnotifications.cpp new file mode 100644 index 000000000..d54747274 --- /dev/null +++ b/test/testpushnotifications.cpp @@ -0,0 +1,279 @@ +#include +#include +#include +#include + +#include "pushnotifications.h" +#include "pushnotificationstestutils.h" + +class TestPushNotifications : public QObject +{ + Q_OBJECT + +private slots: + void testSetup_correctCredentials_authenticateAndEmitReady() + { + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + const QString user = "user"; + const QString password = "password"; + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + QSignalSpy readySpy(account->pushNotifications(), &OCC::PushNotifications::ready); + QVERIFY(readySpy.isValid()); + + // Wait for authentication + QVERIFY(processTextMessageSpy.wait()); + + // Right authentication data should be sent + QCOMPARE(processTextMessageSpy.count(), 2); + + const auto socket = processTextMessageSpy.at(0).at(0).value(); + const auto userSent = processTextMessageSpy.at(0).at(1).toString(); + const auto passwordSent = processTextMessageSpy.at(1).at(1).toString(); + + QCOMPARE(userSent, user); + QCOMPARE(passwordSent, password); + + // Sent authenticated + socket->sendTextMessage("authenticated"); + + // Wait for ready signal + readySpy.wait(); + QCOMPARE(readySpy.count(), 1); + QCOMPARE(account->pushNotifications()->isReady(), true); + } + + void testOnWebSocketTextMessageReceived_notifyFileMessage_emitFilesChanged() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + QSignalSpy filesChangedSpy(account->pushNotifications(), &OCC::PushNotifications::filesChanged); + QVERIFY(filesChangedSpy.isValid()); + + // Wait for authentication and then send notify_file push notification + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + const auto socket = processTextMessageSpy.at(0).at(0).value(); + socket->sendTextMessage("notify_file"); + + // filesChanged signal should be emitted + QVERIFY(filesChangedSpy.wait()); + QCOMPARE(filesChangedSpy.count(), 1); + auto accountFilesChanged = filesChangedSpy.at(0).at(0).value(); + QCOMPARE(accountFilesChanged, account.data()); + } + + void testOnWebSocketTextMessageReceived_notifyActivityMessage_emitNotification() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + QSignalSpy notificationSpy(account->pushNotifications(), &OCC::PushNotifications::notification); + QVERIFY(notificationSpy.isValid()); + + // Wait for authentication and then send notify_file push notification + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + const auto socket = processTextMessageSpy.at(0).at(0).value(); + socket->sendTextMessage("notify_activity"); + + // notification signal should be emitted + QVERIFY(notificationSpy.wait()); + QCOMPARE(notificationSpy.count(), 1); + auto accountFilesChanged = notificationSpy.at(0).at(0).value(); + QCOMPARE(accountFilesChanged, account.data()); + } + + void testOnWebSocketTextMessageReceived_notifyNotificationMessage_emitNotification() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + QSignalSpy notificationSpy(account->pushNotifications(), &OCC::PushNotifications::notification); + QVERIFY(notificationSpy.isValid()); + + // Wait for authentication and then send notify_file push notification + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + const auto socket = processTextMessageSpy.at(0).at(0).value(); + socket->sendTextMessage("notify_notification"); + + // notification signal should be emitted + QVERIFY(notificationSpy.wait()); + QCOMPARE(notificationSpy.count(), 1); + auto accountFilesChanged = notificationSpy.at(0).at(0).value(); + QCOMPARE(accountFilesChanged, account.data()); + } + + void testOnWebSocketTextMessageReceived_invalidCredentialsMessage_reconnectWebSocket() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + // Need to set reconnect timer interval to zero for tests + account->pushNotifications()->setReconnectTimerInterval(0); + + // Wait for authentication attempt and then sent invalid credentials + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + const auto socket = processTextMessageSpy.at(0).at(0).value(); + const auto firstPasswordSent = processTextMessageSpy.at(1).at(1).toString(); + QCOMPARE(firstPasswordSent, password); + processTextMessageSpy.clear(); + socket->sendTextMessage("err: Invalid credentials"); + + // Wait for a new authentication attempt + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + const auto secondPasswordSent = processTextMessageSpy.at(1).at(1).toString(); + QCOMPARE(secondPasswordSent, password); + } + + void testOnWebSocketError_connectionLost_emitConnectionLost() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + // Need to set reconnect timer interval to zero for tests + account->pushNotifications()->setReconnectTimerInterval(0); + + QSignalSpy connectionLostSpy(account->pushNotifications(), &OCC::PushNotifications::connectionLost); + QVERIFY(connectionLostSpy.isValid()); + + // Wait for authentication and then sent a network error + processTextMessageSpy.wait(); + QCOMPARE(processTextMessageSpy.count(), 2); + auto socket = processTextMessageSpy.at(0).at(0).value(); + socket->abort(); + + QVERIFY(connectionLostSpy.wait()); + // Account handled connectionLost signal and deleted PushNotifications + QCOMPARE(account->pushNotifications(), nullptr); + } + + void testSetup_maxConnectionAttemptsReached_deletePushNotifications() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + account->pushNotifications()->setReconnectTimerInterval(0); + QSignalSpy authenticationFailedSpy(account->pushNotifications(), &OCC::PushNotifications::authenticationFailed); + QVERIFY(authenticationFailedSpy.isValid()); + + // Let three authentication attempts fail + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 2); + auto socket = processTextMessageSpy.at(0).at(0).value(); + socket->sendTextMessage("err: Invalid credentials"); + + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 4); + socket = processTextMessageSpy.at(2).at(0).value(); + socket->sendTextMessage("err: Invalid credentials"); + + QVERIFY(processTextMessageSpy.wait()); + QCOMPARE(processTextMessageSpy.count(), 6); + socket = processTextMessageSpy.at(4).at(0).value(); + socket->sendTextMessage("err: Invalid credentials"); + + // Now the authenticationFailed Signal should be emitted + QVERIFY(authenticationFailedSpy.wait()); + QCOMPARE(authenticationFailedSpy.count(), 1); + + // Account deleted the push notifications + QCOMPARE(account->pushNotifications(), nullptr); + } + + void testOnWebSocketSslError_sslError_deletePushNotifications() + { + const QString user = "user"; + const QString password = "password"; + FakeWebSocketServer fakeServer; + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + + auto account = FakeWebSocketServer::createAccount(); + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + + processTextMessageSpy.wait(); + + // FIXME: This a little bit ugly but I had no better idea how to trigger a error on the websocket client. + // The websocket that is retrived through the server is not connected to the ssl error signal. + auto pushNotificationsWebSocketChildren = account->pushNotifications()->findChildren(); + QVERIFY(pushNotificationsWebSocketChildren.size() == 1); + emit pushNotificationsWebSocketChildren[0]->sslErrors(QList()); + + // Account handled connectionLost signal and deleted PushNotifications + QCOMPARE(account->pushNotifications(), nullptr); + } + + void testAccountSetCredentials_correctCredentials_emitPushNotificationsReady() + { + FakeWebSocketServer fakeServer; + auto account = FakeWebSocketServer::createAccount(); + QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage); + QVERIFY(processTextMessageSpy.isValid()); + const QString user = "user"; + const QString password = "password"; + auto credentials = new CredentialsStub(user, password); + account->setCredentials(credentials); + + QSignalSpy pushNotificationsReady(account.data(), &OCC::Account::pushNotificationsReady); + QVERIFY(pushNotificationsReady.isValid()); + + // Wait for authentication + QVERIFY(processTextMessageSpy.wait()); + auto socket = processTextMessageSpy.at(0).at(0).value(); + // Don't care about which message was sent + socket->sendTextMessage("authenticated"); + + // Wait for push notifactions ready signal + QVERIFY(pushNotificationsReady.wait()); + auto accountSent = pushNotificationsReady.at(0).at(0).value(); + QCOMPARE(accountSent, account.data()); + } +}; + +QTEST_GUILESS_MAIN(TestPushNotifications) +#include "testpushnotifications.moc"