diff --git a/src/gui/creds/oauth.h b/src/gui/creds/oauth.h index 702439647..1c6b519e1 100644 --- a/src/gui/creds/oauth.h +++ b/src/gui/creds/oauth.h @@ -16,6 +16,7 @@ #include #include #include +#include "accountfwd.h" namespace OCC { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d5cc8615f..14235a49d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -69,6 +69,8 @@ list(APPEND FolderMan_SRC ${FolderWatcher_SRC}) list(APPEND FolderMan_SRC stub.cpp ) owncloud_add_test(FolderMan "${FolderMan_SRC}") +owncloud_add_test(OAuth "syncenginetestutils.h;../src/gui/creds/oauth.cpp") + configure_file(test_journal.db "${PROJECT_BINARY_DIR}/bin/test_journal.db" COPYONLY) find_package(CMocka) diff --git a/test/syncenginetestutils.h b/test/syncenginetestutils.h index 9253b6285..498ebcb4d 100644 --- a/test/syncenginetestutils.h +++ b/test/syncenginetestutils.h @@ -738,6 +738,10 @@ public: protected: QNetworkReply *createRequest(Operation op, const QNetworkRequest &request, QIODevice *outgoingData = 0) { + if (_override) { + if (auto reply = _override(op, request)) + return reply; + } const QString fileName = getFilePathFromUrl(request.url()); Q_ASSERT(!fileName.isNull()); if (_errorPaths.contains(fileName)) @@ -746,10 +750,6 @@ protected: bool isUpload = request.url().path().startsWith(sUploadUrl.path()); FileInfo &info = isUpload ? _uploadFileInfo : _remoteRootFileInfo; - if (_override) { - if (auto reply = _override(op, request)) - return reply; - } auto verb = request.attribute(QNetworkRequest::CustomVerbAttribute); if (verb == "PROPFIND") diff --git a/test/testoauth.cpp b/test/testoauth.cpp new file mode 100644 index 000000000..76dbb3bc5 --- /dev/null +++ b/test/testoauth.cpp @@ -0,0 +1,281 @@ +/* + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + * + */ + +#include +#include + +#include "gui/creds/oauth.h" +#include "syncenginetestutils.h" +#include "theme.h" +#include "common/asserts.h" + +using namespace OCC; + +class DesktopServiceHook : public QObject +{ + Q_OBJECT +signals: + void hooked(const QUrl &); +public: + DesktopServiceHook() { QDesktopServices::setUrlHandler("oauthtest", this, "hooked"); } +} desktopServiceHook; + +static const QUrl sOAuthTestServer("oauthtest://someserver/owncloud"); + + +class FakePostReply : public QNetworkReply +{ + Q_OBJECT +public: + std::unique_ptr payload; + bool aborted = false; + + FakePostReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, + std::unique_ptr payload_, QObject *parent) + : QNetworkReply{parent}, payload{std::move(payload_)} + { + setRequest(request); + setUrl(request.url()); + setOperation(op); + open(QIODevice::ReadOnly); + payload->open(QIODevice::ReadOnly); + QMetaObject::invokeMethod(this, "respond", Qt::QueuedConnection); + } + + Q_INVOKABLE virtual void respond() { + if (aborted) { + setError(OperationCanceledError, "Operation Canceled"); + emit metaDataChanged(); + emit finished(); + return; + } + setHeader(QNetworkRequest::ContentLengthHeader, payload->size()); + setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 200); + emit metaDataChanged(); + if (bytesAvailable()) + emit readyRead(); + emit finished(); + } + + void abort() override { + aborted = true; + } + qint64 bytesAvailable() const override { + if (aborted) + return 0; + return payload->bytesAvailable(); + } + + qint64 readData(char *data, qint64 maxlen) override { + return payload->read(data, maxlen); + } +}; + +// Reply with a small delay +class SlowFakePostReply : public FakePostReply { + Q_OBJECT +public: + using FakePostReply::FakePostReply; + void respond() override { + // override of FakePostReply::respond, will call the real one with a delay. + QTimer::singleShot(100, this, [this] { this->FakePostReply::respond(); }); + } +}; + + +class OAuthTestCase : public QObject +{ + Q_OBJECT +public: + enum State { StartState, BrowserOpened, TokenAsked, CustomState } state = StartState; + Q_ENUM(State); + bool replyToBrowserOk = false; + bool gotAuthOk = false; + virtual bool done() const { return replyToBrowserOk && gotAuthOk; } + + FakeQNAM *fakeQnam = nullptr; + QNetworkAccessManager realQNAM; + QPointer browserReply = nullptr; + QString code = generateEtag(); + OCC::AccountPtr account; + + QScopedPointer oauth; + + virtual void test() { + fakeQnam = new FakeQNAM({}); + account = OCC::Account::create(); + account->setUrl(sOAuthTestServer); + account->setCredentials(new FakeCredentials{fakeQnam}); + fakeQnam->setParent(this); + fakeQnam->setOverride([this] (QNetworkAccessManager::Operation op, const QNetworkRequest &req) { + return this->tokenReply(op, req); + }); + + QObject::connect(&desktopServiceHook, &DesktopServiceHook::hooked, + this, &OAuthTestCase::openBrowserHook); + + oauth.reset(new OAuth(account.data(), nullptr)); + QObject::connect(oauth.data(), &OAuth::result, this, &OAuthTestCase::oauthResult); + oauth->start(); + QTRY_VERIFY(done()); + } + + virtual void openBrowserHook(const QUrl &url) { + QCOMPARE(state, StartState); + state = BrowserOpened; + QCOMPARE(url.path(), QString(sOAuthTestServer.path() + "/index.php/apps/oauth2/authorize")); + QVERIFY(url.toString().startsWith(sOAuthTestServer.toString())); + QUrlQuery query(url); + QCOMPARE(query.queryItemValue(QLatin1String("response_type")), QLatin1String("code")); + QCOMPARE(query.queryItemValue(QLatin1String("client_id")), Theme::instance()->oauthClientId()); + QUrl redirectUri(query.queryItemValue(QLatin1String("redirect_uri"))); + QCOMPARE(redirectUri.host(), QLatin1String("localhost")); + redirectUri.setQuery("code=" + code); + createBrowserReply(QNetworkRequest(redirectUri)); + } + + virtual QNetworkReply *createBrowserReply(const QNetworkRequest &request) { + browserReply = realQNAM.get(request); + QObject::connect(browserReply, &QNetworkReply::finished, this, &OAuthTestCase::browserReplyFinished); + return browserReply; + } + + virtual void browserReplyFinished() { + QCOMPARE(sender(), browserReply.data()); + QCOMPARE(state, TokenAsked); + browserReply->deleteLater(); + QCOMPARE(browserReply->rawHeader("Location"), QByteArray("owncloud://success")); + replyToBrowserOk = true; + }; + + virtual QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) + { + ASSERT(state == BrowserOpened); + state = TokenAsked; + ASSERT(op == QNetworkAccessManager::PostOperation); + ASSERT(req.url().toString().startsWith(sOAuthTestServer.toString())); + ASSERT(req.url().path() == sOAuthTestServer.path() + "/index.php/apps/oauth2/api/v1/token"); + std::unique_ptr payload(new QBuffer()); + payload->setData(tokenReplyPayload()); + return new FakePostReply(op, req, std::move(payload), fakeQnam); + } + + virtual QByteArray tokenReplyPayload() const { + QJsonDocument jsondata(QJsonObject{ + { "access_token", "123" }, + { "refresh_token" , "456" }, + { "message_url", "owncloud://success"}, + { "user_id", "789" }, + { "token_type", "Bearer" } + }); + return jsondata.toJson(); + } + + virtual void oauthResult(OAuth::Result result, const QString &user, const QString &token , const QString &refreshToken) { + QCOMPARE(state, TokenAsked); + QCOMPARE(result, OAuth::LoggedIn); + QCOMPARE(user, QString("789")); + QCOMPARE(token, QString("123")); + QCOMPARE(refreshToken, QString("456")); + gotAuthOk = true; + } +}; + +class TestOAuth: public QObject +{ + Q_OBJECT + +private slots: + void testBasic() + { + OAuthTestCase test; + test.test(); + } + + // Test for https://github.com/owncloud/client/pull/6057 + void testCloseBrowserDontCrash() + { + struct Test : OAuthTestCase { + QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest & req) override + { + ASSERT(browserReply); + // simulate the fact that the browser is closing the connection + browserReply->abort(); + QCoreApplication::processEvents(); + + ASSERT(state == BrowserOpened); + state = TokenAsked; + + std::unique_ptr payload(new QBuffer); + payload->setData(tokenReplyPayload()); + return new SlowFakePostReply(op, req, std::move(payload), fakeQnam); + } + + void browserReplyFinished() override + { + QCOMPARE(sender(), browserReply.data()); + QCOMPARE(browserReply->error(), QNetworkReply::OperationCanceledError); + replyToBrowserOk = true; + } + } test; + test.test(); + } + + void testRandomConnections() + { + // Test that we can send random garbage to the litening socket and it does not prevent the connection + struct Test : OAuthTestCase { + virtual QNetworkReply *createBrowserReply(const QNetworkRequest &request) override { + QTimer::singleShot(0, this, [this, request] { + auto port = request.url().port(); + state = CustomState; + QVector payloads = { + "GET FOFOFO HTTP 1/1\n\n", + "GET /?code=invalie HTTP 1/1\n\n", + "GET /?code=xxxxx&bar=fff", + QByteArray("\0\0\0", 3), + QByteArray("GET \0\0\0 \n\n\n\n\n\0", 14), + QByteArray("GET /?code=éléphant\xa5 HTTP\n"), + QByteArray("\n\n\n\n"), + }; + foreach (const auto &x, payloads) { + auto socket = new QTcpSocket(this); + socket->connectToHost("localhost", port); + QVERIFY(socket->waitForConnected()); + socket->write(x); + } + + // Do the actual request a bit later + QTimer::singleShot(100, this, [this, request] { + QCOMPARE(state, CustomState); + state = BrowserOpened; + this->OAuthTestCase::createBrowserReply(request); + }); + }); + return nullptr; + } + + QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) override + { + if (state == CustomState) + return new FakeErrorReply{op, req, this, 500}; + return OAuthTestCase::tokenReply(op, req); + } + + void oauthResult(OAuth::Result result, const QString &user, const QString &token , + const QString &refreshToken) override { + if (state != CustomState) + return OAuthTestCase::oauthResult(result, user, token, refreshToken); + QCOMPARE(result, OAuth::Error); + } + } test; + test.test(); + } +}; + +QTEST_GUILESS_MAIN(TestOAuth) +#include "testoauth.moc"