Revision control
++ // Copyright (C) 2017 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QtNetworkAuth/qoauth2authorizationcodeflow.h>
++ #include <QtNetworkAuth/qoauthhttpserverreplyhandler.h>
++
++ #include <QtCore>
++ #include <QtTest>
++ #include <QtNetwork>
++
++ #include "webserver.h"
++
++ typedef QSharedPointer<QNetworkReply> QNetworkReplyPtr;
++
++ static constexpr std::chrono::seconds Timeout(20);
++
++ using namespace Qt::StringLiterals;
++
++ class tst_QOAuthHttpServerReplyHandler : public QObject
++ {
++ Q_OBJECT
++
++ private Q_SLOTS:
++ void initTestCase();
++ void tokenReplyErrors_data();
++ void tokenReplyErrors();
++ void tokenReplyContentFormats_data();
++ void tokenReplyContentFormats();
++ void callback_data();
++ void callback();
++ void callbackCaching();
++ void callbackWithQuery();
++ void badCallbackUris_data();
++ void badCallbackUris();
++ void badCallbackWrongMethod();
++ void callbackDataReceived_data();
++ void callbackDataReceived();
++ #ifndef QT_NO_SSL
++ void localhostHttps();
++ #endif
++ void callbackHost_data();
++ void callbackHost();
++
++ private:
++ QString testDataDir;
++ [[nodiscard]] auto useTemporaryKeychain()
++ {
++ #ifndef QT_NO_SSL
++ // Set the same environment value as CI uses, so that it's possible
++ // to run autotests locally without macOS asking for permission to use
++ // a private key in keychain (with TLS sockets)
++ auto value = qEnvironmentVariable("QT_SSL_USE_TEMPORARY_KEYCHAIN");
++ qputenv("QT_SSL_USE_TEMPORARY_KEYCHAIN", "1");
++ auto envRollback = qScopeGuard([value](){
++ if (value.isEmpty())
++ qunsetenv("QT_SSL_USE_TEMPORARY_KEYCHAIN");
++ else
++ qputenv("QT_SSL_USE_TEMPORARY_KEYCHAIN", value.toUtf8());
++ });
++ return envRollback;
++ #else
++ // avoid maybe-unused warnings from callers
++ return qScopeGuard([]{});
++ #endif // QT_NO_SSL
++ }
++ };
++
++ void tst_QOAuthHttpServerReplyHandler::initTestCase()
++ {
++ #ifdef BUILTIN_TESTDATA
++ testDataDir = QStringLiteral(":/shared/");
++ #else
++ testDataDir = QFileInfo(QFINDTESTDATA("certs")).absolutePath();
++ if (testDataDir.isEmpty())
++ testDataDir = QCoreApplication::applicationDirPath();
++ if (!testDataDir.endsWith(QLatin1String("/")))
++ testDataDir += QLatin1String("/");
++ #endif
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::tokenReplyErrors_data()
++ {
++ using Error = QAbstractOAuth::Error;
++ QString tokenResponse;
++ QTest::addColumn<QString>("tokenResponse");
++ QTest::addColumn<Error>("expectedError");
++ // Note: For some rows the 'warning detail' is left empty, because the detail is from
++ // QNetworkReply, and could change over time
++ QTest::addColumn<QString>("expectedWarningDetails");
++
++ tokenResponse = "\0"_L1;
++ QTest::addRow("network-error") << tokenResponse << Error::NetworkError << QString();
++
++ tokenResponse = "HTTP/1.1 400 Bad Request\r\n"_L1
++ "\r\n"_L1;
++ QTest::addRow("400-bad-request") << tokenResponse << Error::ServerError << QString();
++
++ tokenResponse = "HTTP/1.1 200 OK\r\n"_L1
++ "\r\n"_L1;
++ QTest::addRow("missing-content-type")
++ << tokenResponse << Error::ServerError << u"Empty Content-type header"_s;
++
++ tokenResponse = "HTTP/1.1 200 OK\r\n"_L1
++ "Content-Type: application/json\r\n"_L1
++ "\r\n"_L1;
++ QTest::addRow("missing-body-data")
++ << tokenResponse << Error::ServerError << u"No data received"_s;
++
++ tokenResponse = "HTTP/1.1 200 OK\r\n"_L1
++ "Content-Type: application/json\r\n"_L1
++ "\r\n"_L1
++ "not valid json"_L1;
++ QTest::addRow("content-not-valid-json")
++ << tokenResponse << Error::ServerError << u"Received data is not a JSON object"_s;
++
++ tokenResponse = "HTTP/1.1 200 OK\r\n"_L1
++ "Content-Type: application/json\r\n"_L1
++ "\r\n"_L1
++ "{}"_L1;
++ QTest::addRow("empty-json")
++ << tokenResponse << Error::ServerError << u"Received an empty JSON object"_s;
++
++ tokenResponse = "HTTP/1.1 200 OK\r\n"_L1
++ "Content-Type: application/x-quantum-data-stream\r\n"_L1
++ "\r\n"_L1
++ "this statement is false"_L1;
++ QTest::addRow("unknown-content-type")
++ << tokenResponse << Error::ServerError << u"Unknown Content-type"_s;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::tokenReplyErrors()
++ {
++ using Error = QAbstractOAuth::Error;
++ QFETCH(const QString, tokenResponse);
++ QFETCH(Error, expectedError);
++ QFETCH(const QString, expectedWarningDetails);
++
++ WebServer authorizationServer([&](const WebServer::HttpRequest &request, QTcpSocket *socket) {
++ if (request.url.path() == QLatin1String("/tokenEndpoint"))
++ socket->write(tokenResponse.toUtf8());
++ });
++
++ constexpr auto expectWarning = [](const QString &warningText) {
++ const QRegularExpression warning{u"Token request failed: \""_s + warningText};
++ QTest::ignoreMessage(QtWarningMsg, warning);
++ };
++
++ QOAuth2AuthorizationCodeFlow oauth2;
++ QOAuthHttpServerReplyHandler replyHandler;
++ oauth2.setReplyHandler(&replyHandler);
++ oauth2.setAuthorizationUrl(QUrl{"authorizationEndpoint"_L1});
++ oauth2.setTokenUrl(authorizationServer.url("tokenEndpoint"_L1));
++ oauth2.setState("a-state"_L1);
++ QSignalSpy tokenErrorSpy(&replyHandler, &QAbstractOAuthReplyHandler::tokenRequestErrorOccurred);
++
++ oauth2.grant();
++ // Conclude authorization stage so that authorization server gets a token request
++ emit replyHandler.callbackReceived({{ "code"_L1, "a-code"_L1 }, { "state"_L1, "a-state"_L1 }});
++ expectWarning(expectedWarningDetails);
++ QTRY_COMPARE(tokenErrorSpy.size(), 1);
++ QCOMPARE(tokenErrorSpy.at(0).at(0).value<Error>(), expectedError);
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::tokenReplyContentFormats_data()
++ {
++ QTest::addColumn<QByteArray>("tokenResponse");
++
++ QByteArray tokenResponse =
++ "HTTP/1.1 200 OK\r\n"
++ "Content-Type: application/json\r\n"
++ "\r\n"
++ R"(
++ {
++ "access_token": "an-access-token",
++ "refresh_token": "a-refresh-token",
++ "expires_in": 3600
++ })";
++ QTest::addRow("application/json") << tokenResponse;
++
++ tokenResponse =
++ "HTTP/1.1 200 OK\r\n"
++ "Content-Type: text/html\r\n"
++ "\r\n"
++ "access_token=an-access-token&"
++ "refresh_token=a-refresh-token&"
++ "expires_in=3600";
++ QTest::addRow("text/html") << tokenResponse;
++
++ tokenResponse =
++ "HTTP/1.1 200 OK\r\n"
++ "Content-Type: application/x-www-form-urlencoded\r\n"
++ "\r\n"
++ "access_token=an-access-token&"
++ "refresh_token=a-refresh-token&"
++ "expires_in=3600";
++ QTest::addRow("application/x-www-form-urlencoded") << tokenResponse;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::tokenReplyContentFormats()
++ {
++ QFETCH(const QByteArray, tokenResponse);
++
++ WebServer authorizationServer([&](const WebServer::HttpRequest &request, QTcpSocket *socket) {
++ if (request.url.path() == QLatin1String("/tokenEndpoint"))
++ socket->write(tokenResponse);
++ });
++
++ QOAuth2AuthorizationCodeFlow oauth2;
++ QOAuthHttpServerReplyHandler replyHandler;
++ oauth2.setReplyHandler(&replyHandler);
++ oauth2.setAuthorizationUrl(QUrl{"authorizationEndpoint"_L1});
++ oauth2.setTokenUrl(authorizationServer.url("tokenEndpoint"_L1));
++ oauth2.setState("a-state"_L1);
++ QSignalSpy tokenDataReceivedSpy(&replyHandler, &QOAuthHttpServerReplyHandler::tokensReceived);
++
++ oauth2.grant();
++
++ // Conclude authorization stage so that authorization server gets a token request
++ emit replyHandler.callbackReceived({{ "code"_L1, "a-code"_L1 }, { "state"_L1, "a-state"_L1 }});
++ QTRY_COMPARE_EQ(tokenDataReceivedSpy.size(), 1);
++
++ const auto tokenData = tokenDataReceivedSpy.at(0).at(0).toMap();
++ QCOMPARE_EQ(tokenData.size(), 3);
++
++ QCOMPARE_EQ(oauth2.token(), "an-access-token"_L1);
++ QCOMPARE_EQ(tokenData.value("access_token"_L1), oauth2.token());
++
++ QCOMPARE_EQ(oauth2.refreshToken(), "a-refresh-token"_L1);
++ QCOMPARE_EQ(tokenData.value("refresh_token"_L1), oauth2.refreshToken());
++
++ QCOMPARE_EQ(tokenData.value("expires_in"_L1), 3600);
++ QVERIFY(oauth2.expirationAt().isValid());
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callback_data()
++ {
++ QTest::addColumn<QString>("callbackPath");
++ QTest::addColumn<QString>("uri");
++ QTest::addColumn<bool>("success");
++
++ QTest::newRow("default") << QString() << QString() << true;
++ QTest::newRow("empty") << "" << QString() << true;
++ QTest::newRow("ascii-path") << "/foobar" << QString() << true;
++ QTest::newRow("utf8-path") << "/áéÃóú" << QString() << true;
++ QTest::newRow("questionmark") << "/?" << QString() << true;
++ QTest::newRow("hash") << "/#" << QString() << true;
++
++ QTest::newRow("default-fragment") << QString() << "/#shouldntsee" << true;
++ QTest::newRow("default-query") << QString() << "/?some=query" << true;
++
++ QTest::newRow("default-wrongpath") << QString() << "/foo" << false;
++ QTest::newRow("changed-wrongpath") << "/foo" << "/bar" << false;
++ QTest::newRow("changed-wrongpathprefix") << "/foo" << "/foobar" << false;
++ QTest::newRow("changed-wrongpathprefixpath") << "/foo" << "/foo/bar" << false;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callback()
++ {
++ QFETCH(QString, callbackPath);
++ QFETCH(QString, uri);
++ QFETCH(bool, success);
++
++ int count = 0;
++ QOAuthHttpServerReplyHandler replyHandler;
++ QVERIFY(replyHandler.isListening());
++ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
++ const QVariantMap &) {
++ ++count;
++ QTestEventLoop::instance().exitLoop();
++ });
++
++ if (!callbackPath.isNull())
++ replyHandler.setCallbackPath(callbackPath);
++ QUrl callback(replyHandler.callback());
++ QVERIFY(!callback.isEmpty());
++
++ // maybe change the URL
++ callback = callback.resolved(QUrl(uri));
++
++ QNetworkAccessManager networkAccessManager;
++ QNetworkRequest request(callback);
++ QNetworkReplyPtr reply;
++ reply.reset(networkAccessManager.get(request));
++ connect(reply.get(), &QNetworkReply::finished, &QTestEventLoop::instance(),
++ &QTestEventLoop::exitLoop);
++
++ if (!success) {
++ for (int i = 0; i < 2; ++i) // emitted more than once
++ QTest::ignoreMessage(QtWarningMsg, "Invalid request: URL path doesn't match");
++ }
++ QTestEventLoop::instance().enterLoop(Timeout);
++ QCOMPARE(count > 0, success);
++ QVERIFY(!QTestEventLoop::instance().timeout());
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callbackCaching()
++ {
++ QOAuthHttpServerReplyHandler replyHandler;
++ constexpr auto callbackPath = "/foo"_L1;
++ constexpr auto callbackHost = "127.0.0.1"_L1;
++
++ QVERIFY(replyHandler.isListening());
++ replyHandler.setCallbackPath(callbackPath);
++ QUrl callback{replyHandler.callback()};
++ QCOMPARE(callback.path(), callbackPath);
++ QCOMPARE(callback.host(), callbackHost);
++
++ replyHandler.close();
++ QVERIFY(!replyHandler.isListening());
++ callback.setUrl(replyHandler.callback());
++ // Should remain after close
++ QCOMPARE(callback.path(), callbackPath);
++ QCOMPARE(callback.host(), callbackHost);
++
++ replyHandler.listen({QHostAddress::SpecialAddress::LocalHost});
++ QVERIFY(replyHandler.isListening());
++ callback.setUrl(replyHandler.callback());
++ QCOMPARE(callback.path(), callbackPath);
++ QCOMPARE(callback.host(), callbackHost);
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callbackWithQuery()
++ {
++ int count = 0;
++ QOAuthHttpServerReplyHandler replyHandler;
++ QUrlQuery query("callback=test");
++ QVERIFY(replyHandler.isListening());
++ QUrl callback(replyHandler.callback());
++ QVERIFY(!callback.isEmpty());
++ callback.setQuery(query);
++
++ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
++ const QVariantMap ¶meters) {
++ for (auto item : query.queryItems()) {
++ QVERIFY(parameters.contains(item.first));
++ QCOMPARE(parameters[item.first].toString(), item.second);
++ }
++ count = parameters.size();
++ QTestEventLoop::instance().exitLoop();
++ });
++
++ QNetworkAccessManager networkAccessManager;
++ QNetworkRequest request;
++ request.setUrl(callback);
++ QNetworkReplyPtr reply;
++ reply.reset(networkAccessManager.get(request));
++ connect(reply.get(), &QNetworkReply::finished, &QTestEventLoop::instance(),
++ &QTestEventLoop::exitLoop);
++ QTestEventLoop::instance().enterLoop(Timeout);
++ QCOMPARE(count, query.queryItems().size());
++ QVERIFY(!QTestEventLoop::instance().timeout());
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::badCallbackUris_data()
++ {
++ QTest::addColumn<QString>("uri");
++
++ QTest::newRow("relative-path") << "foobar";
++ QTest::newRow("encoded-slash") << "%2F";
++ QTest::newRow("query") << "?some=query";
++ QTest::newRow("full-url") << "http://localhost/";
++ QTest::newRow("authority") << "//localhost";
++ // requires QUrl fix
++ //QTest::newRow("double-slash") << "//";
++ //QTest::newRow("triple-slash") << "///";
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::badCallbackUris()
++ {
++ QFETCH(QString, uri);
++
++ int count = 0;
++ QOAuthHttpServerReplyHandler replyHandler;
++ QVERIFY(replyHandler.isListening());
++ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
++ const QVariantMap &) {
++ ++count;
++ QTestEventLoop::instance().exitLoop();
++ });
++ QUrl callback(replyHandler.callback());
++ QVERIFY(!callback.isEmpty());
++
++ QTcpSocket socket;
++ socket.connectToHost(QHostAddress::LocalHost, replyHandler.port());
++ socket.write("GET " + uri.toLocal8Bit() + " HTTP/1.0\r\n"
++ "Host: localhost\r\n"
++ "\r\n");
++ connect(&socket, &QTcpSocket::disconnected, &QTestEventLoop::instance(),
++ &QTestEventLoop::exitLoop);
++
++ QTest::ignoreMessage(QtWarningMsg, "Invalid request: invalid request-target");
++ QTest::ignoreMessage(QtWarningMsg, "Invalid URL");
++
++ QTestEventLoop::instance().enterLoop(Timeout);
++ QCOMPARE(count, 0);
++ QVERIFY(!QTestEventLoop::instance().timeout());
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::badCallbackWrongMethod()
++ {
++ int count = 0;
++ QOAuthHttpServerReplyHandler replyHandler;
++ QVERIFY(replyHandler.isListening());
++ connect(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived, this, [&](
++ const QVariantMap &) {
++ ++count;
++ QTestEventLoop::instance().exitLoop();
++ });
++ QUrl callback(replyHandler.callback());
++ QVERIFY(!callback.isEmpty());
++
++ QTcpSocket socket;
++ socket.connectToHost(QHostAddress::LocalHost, replyHandler.port());
++ socket.write("EHLO localhost\r\n");
++ connect(&socket, &QTcpSocket::disconnected, &QTestEventLoop::instance(),
++ &QTestEventLoop::exitLoop);
++
++ QTest::ignoreMessage(QtWarningMsg, "Invalid operation EHLO");
++ QTest::ignoreMessage(QtWarningMsg, "Invalid Method");
++
++ QTestEventLoop::instance().enterLoop(Timeout);
++ QCOMPARE(count, 0);
++ QVERIFY(!QTestEventLoop::instance().timeout());
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callbackDataReceived_data()
++ {
++ QTest::addColumn<QString>("redirect_response_data");
++
++ QTest::addRow("no_data") << u""_s;
++ QTest::addRow("query_parameters") << u"?k1=v1"_s;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callbackDataReceived()
++ {
++ QFETCH(const QString, redirect_response_data);
++
++ QOAuthHttpServerReplyHandler replyHandler;
++ QSignalSpy spy(&replyHandler, &QOAuthHttpServerReplyHandler::callbackDataReceived);
++ QVERIFY(replyHandler.isListening());
++
++ QString expected_response_data = replyHandler.callback() + redirect_response_data;
++
++ QNetworkAccessManager networkAccessManager;
++ QNetworkRequest request(QUrl{expected_response_data});
++ QNetworkReplyPtr reply;
++
++ reply.reset(networkAccessManager.get(request));
++
++ QTRY_COMPARE(spy.size(), 1);
++ QCOMPARE(spy.at(0).at(0).toByteArray(), expected_response_data.toLatin1());
++ }
++
++ #ifndef QT_NO_SSL
++ static QSslConfiguration createSslConfiguration(QString keyFileName, QString certificateFileName)
++ {
++ QSslConfiguration configuration(QSslConfiguration::defaultConfiguration());
++
++ QFile keyFile(keyFileName);
++ if (keyFile.open(QIODevice::ReadOnly)) {
++ QSslKey key(keyFile.readAll(), QSsl::Rsa, QSsl::Pem, QSsl::PrivateKey);
++ if (!key.isNull()) {
++ configuration.setPrivateKey(key);
++ } else {
++ qCritical() << "Could not parse key: " << keyFileName;
++ }
++ } else {
++ qCritical() << "Could not find key: " << keyFileName;
++ }
++
++ QList<QSslCertificate> localCert = QSslCertificate::fromPath(certificateFileName);
++ if (!localCert.isEmpty() && !localCert.first().isNull()) {
++ configuration.setLocalCertificate(localCert.first());
++ } else {
++ qCritical() << "Could not find certificate: " << certificateFileName;
++ }
++ return configuration;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::localhostHttps()
++ {
++ if (!QSslSocket::supportsSsl())
++ QSKIP("This test will fail because the backend does not support TLS");
++
++ auto rollback = useTemporaryKeychain();
++
++ // erros may vary, depending on backend
++ const QSet<QSslError::SslError> expectedSslErrors{ QSslError::SelfSignedCertificate,
++ QSslError::CertificateUntrusted,
++ QSslError::HostNameMismatch };
++ auto serverConfig = createSslConfiguration(testDataDir + "certs/selfsigned-server.key",
++ testDataDir + "certs/selfsigned-server.crt");
++
++ QOAuthHttpServerReplyHandler replyHandler;
++ replyHandler.setCallbackPath(u"/callback"_s);
++
++ // Initially the handler is a plain 'http' handler
++ QVERIFY(replyHandler.isListening());
++ QCOMPARE(QUrl(replyHandler.callback()).scheme(), u"http"_s);
++
++ // Calling listen() with SSL configuration makes handler to use 'https'
++ QVERIFY(replyHandler.listen(serverConfig));
++ QVERIFY(replyHandler.isListening());
++ const QUrl redirectUrl{replyHandler.callback() + "?state=somestate&code=somecode"_L1};
++ QCOMPARE(redirectUrl.scheme(), u"https"_s);
++
++ // Issue a HTTP GET to the handler's server to mimic OAuth2 redirection event
++ QSignalSpy spy(&replyHandler, &QOAuthHttpServerReplyHandler::callbackReceived);
++ QNetworkAccessManager networkAccessManager;
++ QNetworkRequest networkRequest(redirectUrl);
++ QNetworkReplyPtr reply;
++
++ connect(&networkAccessManager, &QNetworkAccessManager::sslErrors, this,
++ [&expectedSslErrors](QNetworkReply *reply, const QList<QSslError> &errors) {
++ for (const auto &error : errors)
++ QVERIFY(expectedSslErrors.contains(error.error()));
++ reply->ignoreSslErrors();
++ });
++
++ reply.reset(networkAccessManager.get(networkRequest));
++ QTRY_COMPARE(spy.size(), 1);
++ const auto parameters = spy.at(0).at(0).toMap();
++ QCOMPARE(parameters["state"_L1].toString(), u"somestate"_s);
++ QCOMPARE(parameters["code"_L1].toString(), u"somecode"_s);
++
++ // Call listen with invalid SSL configuration
++ QTest::ignoreMessage(QtWarningMsg, "QSslConfiguration is null, cannot listen");
++ QVERIFY(!replyHandler.listen(QSslConfiguration{}));
++ QVERIFY(!replyHandler.isListening());
++
++ // Call listen() without SSL configuration and verify it uses plain 'http' again
++ QVERIFY(replyHandler.listen());
++ QVERIFY(replyHandler.isListening());
++ QCOMPARE(QUrl(replyHandler.callback()).scheme(), u"http"_s);
++ }
++ #endif // QT_NO_SSL
++
++ void tst_QOAuthHttpServerReplyHandler::callbackHost_data()
++ {
++ QTest::addColumn<QHostAddress>("hostAddress");
++ QTest::addColumn<QString>("hostName");
++ QTest::addColumn<QUrl>("expectedCallback");
++
++ const QString localhost = u"localhost"_s;
++ const QUrl localhostCallback{u"http://localhost/"_s};
++
++ const QString ipv4literal = u"127.0.0.1"_s;
++ const QUrl ipv4literalCallback{u"http://127.0.0.1/"_s};
++
++ const QString ipv6literal = u"::1"_s;
++ const QUrl ipv6literalCallback{u"http://[::1]/"_s};
++
++ const QString localnet = u"localhost.localnet"_s;
++ const QUrl localnetCallback{u"http://localhost.localnet/"_s};
++
++ QTest::newRow("default")
++ << QHostAddress() << QString() << ipv4literalCallback;
++ QTest::newRow("LocalHost")
++ << QHostAddress(QHostAddress::SpecialAddress::LocalHost)
++ << QString() << ipv4literalCallback;
++ QTest::newRow("127.0.0.1")
++ << QHostAddress(ipv4literal) << QString() << ipv4literalCallback;
++ QTest::newRow("LocalHostIPv6")
++ << QHostAddress(QHostAddress::SpecialAddress::LocalHostIPv6) << QString()
++ << ipv6literalCallback;
++ QTest::newRow("::1")
++ << QHostAddress(ipv6literal) << QString() << ipv6literalCallback;
++ QTest::newRow("AnyIPv4")
++ << QHostAddress(QHostAddress::SpecialAddress::AnyIPv4) << QString() << localhostCallback;
++ QTest::newRow("0.0.0.0")
++ << QHostAddress("0.0.0.0") << QString() << localhostCallback;
++ QTest::newRow("AnyIPv6")
++ << QHostAddress(QHostAddress::SpecialAddress::AnyIPv6) << QString() << localhostCallback;
++ QTest::newRow("::") << QHostAddress("::") << QString() << localhostCallback;
++
++ // Following rows test that manually set hostname always takes precedence
++ QTest::newRow("localhost.localnet(default)")
++ << QHostAddress() << localnet << localnetCallback;
++ QTest::newRow("localhost.localnet(AnyIPv4)")
++ << QHostAddress(QHostAddress::SpecialAddress::AnyIPv4) << localnet << localnetCallback;
++ QTest::newRow("localhost.localnet(AnyIPv6)")
++ << QHostAddress(QHostAddress::SpecialAddress::AnyIPv6) << localnet << localnetCallback;
++ QTest::newRow("localhost.localnet(127.0.0.1)")
++ << QHostAddress(ipv4literal) << localnet << localnetCallback;
++ }
++
++ void tst_QOAuthHttpServerReplyHandler::callbackHost()
++ {
++ QFETCH(const QHostAddress, hostAddress);
++ QFETCH(const QString, hostName);
++ QFETCH(QUrl, expectedCallback);
++
++ QOAuthHttpServerReplyHandler replyHandler(hostAddress, 0); // 0 for any port
++ if (!hostName.isEmpty())
++ replyHandler.setCallbackHost(hostName);
++
++ QVERIFY(replyHandler.isListening());
++ expectedCallback.setPort(replyHandler.port());
++ QCOMPARE(QUrl(replyHandler.callback()), expectedCallback);
++ QCOMPARE(replyHandler.callbackHost(), expectedCallback.host());
++ }
++
++ QTEST_MAIN(tst_QOAuthHttpServerReplyHandler)
++ #include "tst_oauthhttpserverreplyhandler.moc"