#include "filesystem.h"
#include "lockwatcher.h"
#include "common/asserts.h"
+#include <pushnotifications.h>
#include <syncengine.h>
#ifdef Q_OS_MAC
connect(_lockWatcher.data(), &LockWatcher::fileUnlocked,
this, &FolderMan::slotWatchedFileUnlocked);
+
+ connect(this, &FolderMan::folderListChanged, this, &FolderMan::slotSetupPushNotifications);
}
FolderMan *FolderMan::instance()
}
}
+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;
+ const auto folderMapValues = _folderMap.values();
+
+ qCInfo(lcFolderMan) << "Folders to sync:" << folderMapValues.size();
+
+ QList<Folder *> 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<Folder *> &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(f, "slotRunEtagJob", Qt::QueuedConnection);
}
+
+ QMetaObject::invokeMethod(folder, "slotRunEtagJob", Qt::QueuedConnection);
}
void FolderMan::slotRemoveFoldersForAccount(AccountState *accountState)
}
}
+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
*/
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.
void setupFoldersHelper(QSettings &settings, AccountStatePtr account, const QStringList &ignoreKeys, bool backwardsCompatible, bool foldersWithPlaceholders);
+ void runEtagJobsIfPossible(const QList<Folder *> &folderMap);
+ void runEtagJobIfPossible(Folder *folder);
+
+ bool pushNotificationsFilesReady(Account *account);
+
QSet<Folder *> _disabledFolders;
Folder::Map _folderMap;
QString _folderConfigPath;
set(libsync_SRCS
account.cpp
+ pushnotifications.cpp
wordlist.cpp
bandwidthmanager.cpp
capabilities.cpp
)
ENDIF(NOT APPLE)
+find_package(Qt5 REQUIRED COMPONENTS WebSockets)
add_library(${synclib_NAME} SHARED ${libsync_SRCS})
target_link_libraries(${synclib_NAME}
"${csync_NAME}"
OpenSSL::SSL
${OS_SPECIFIC_LINK_LIBRARIES}
Qt5::Core Qt5::Network
+ Qt5::WebSockets
)
if (NOT TOKEN_AUTH_ONLY)
#include "creds/abstractcredentials.h"
#include "capabilities.h"
#include "theme.h"
+#include "pushnotifications.h"
#include "common/asserts.h"
#include "clientsideencryption.h"
, _davPath(Theme::instance()->webDavPath())
{
qRegisterMetaType<AccountPtr>("AccountPtr");
+ qRegisterMetaType<Account *>("Account*");
}
AccountPtr Account::create()
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
void Account::setCapabilities(const QVariantMap &caps)
{
_capabilities = Capabilities(caps);
+
+ trySetupPushNotifications();
}
QString Account::serverVersion() const
}
}
+PushNotifications *Account::pushNotifications() const
+{
+ return _pushNotifications;
+}
+
} // namespace OCC
using AccountPtr = QSharedPointer<Account>;
class AccessManager;
class SimpleNetworkJob;
+class PushNotifications;
/**
* @brief Reimplement this to handle SSL errors from libsync
// Check for the directEditing capability
void fetchDirectEditors(const QUrl &directEditingURL, const QString &directEditingETag);
+ PushNotifications *pushNotifications() const;
+
public slots:
/// Used when forgetting credentials
void clearQNAMCache();
/// Used in RemoteWipe
void appPasswordRetrieved(QString);
+ void pushNotificationsReady(Account *account);
+
protected Q_SLOTS:
void slotCredentialsFetched();
void slotCredentialsAsked();
private:
Account(QObject *parent = nullptr);
void setSharedThis(AccountPtr sharedThis);
+ void trySetupPushNotifications();
QWeakPointer<Account> _sharedThis;
QString _id;
// 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!
*
}
Q_DECLARE_METATYPE(OCC::AccountPtr)
+Q_DECLARE_METATYPE(OCC::Account *)
#endif //SERVERCONNECTION_H
#include <QVariantMap>
#include <QLoggingCategory>
+#include <QUrl>
#include <QDebug>
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();
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
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;
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. */
return _user;
}
+QString DummyCredentials::password() const
+{
+ Q_UNREACHABLE();
+ return QString();
+}
+
QNetworkAccessManager *DummyCredentials::createQNAM() const
{
return new AccessManager;
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;
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();
--- /dev/null
+#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<QSslError> &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<QAbstractSocket::SocketError>::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);
+}
+}
--- /dev/null
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * 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 <QWebSocket>
+#include <QTimer>
+
+#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<QSslError> &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;
+};
+
+}
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}")
--- /dev/null
+#include <QLoggingCategory>
+#include <QSignalSpy>
+#include <QTest>
+
+#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<QWebSocket *>(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<QWebSocket *>(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() { }
--- /dev/null
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * 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 <functional>
+
+#include <QWebSocketServer>
+#include <QWebSocket>
+
+#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<QWebSocket *> _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;
+};
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() { }
--- /dev/null
+#include <QTest>
+
+#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"
--- /dev/null
+#include <QTest>
+#include <QVector>
+#include <QWebSocketServer>
+#include <QSignalSpy>
+
+#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<QWebSocket *>();
+ 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<QWebSocket *>();
+ socket->sendTextMessage("notify_file");
+
+ // filesChanged signal should be emitted
+ QVERIFY(filesChangedSpy.wait());
+ QCOMPARE(filesChangedSpy.count(), 1);
+ auto accountFilesChanged = filesChangedSpy.at(0).at(0).value<OCC::Account *>();
+ 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<QWebSocket *>();
+ socket->sendTextMessage("notify_activity");
+
+ // notification signal should be emitted
+ QVERIFY(notificationSpy.wait());
+ QCOMPARE(notificationSpy.count(), 1);
+ auto accountFilesChanged = notificationSpy.at(0).at(0).value<OCC::Account *>();
+ 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<QWebSocket *>();
+ socket->sendTextMessage("notify_notification");
+
+ // notification signal should be emitted
+ QVERIFY(notificationSpy.wait());
+ QCOMPARE(notificationSpy.count(), 1);
+ auto accountFilesChanged = notificationSpy.at(0).at(0).value<OCC::Account *>();
+ 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<QWebSocket *>();
+ 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<QWebSocket *>();
+ 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<QWebSocket *>();
+ socket->sendTextMessage("err: Invalid credentials");
+
+ QVERIFY(processTextMessageSpy.wait());
+ QCOMPARE(processTextMessageSpy.count(), 4);
+ socket = processTextMessageSpy.at(2).at(0).value<QWebSocket *>();
+ socket->sendTextMessage("err: Invalid credentials");
+
+ QVERIFY(processTextMessageSpy.wait());
+ QCOMPARE(processTextMessageSpy.count(), 6);
+ socket = processTextMessageSpy.at(4).at(0).value<QWebSocket *>();
+ 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<QWebSocket *>();
+ QVERIFY(pushNotificationsWebSocketChildren.size() == 1);
+ emit pushNotificationsWebSocketChildren[0]->sslErrors(QList<QSslError>());
+
+ // 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<QWebSocket *>();
+ // 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<OCC::Account *>();
+ QCOMPARE(accountSent, account.data());
+ }
+};
+
+QTEST_GUILESS_MAIN(TestPushNotifications)
+#include "testpushnotifications.moc"