diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index f8ab143..8054ec9 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -32,6 +32,11 @@ void AppController::initialize() { m_security->initialize(); m_server->setSslConfiguration(m_security->sslConfiguration()); + // The LocalSend protocol requires mutual TLS: sending our files to another + // device means our HTTP client must present this device's certificate during + // the handshake, or the receiver rejects the connection with + // "tlsv13 alert certificate required". + m_httpClient->setSslConfiguration(m_security->sslConfiguration()); LocalSend::InfoDto info = buildInfoDto(); m_server->setLocalInfo(info, m_security->fingerprint()); @@ -255,10 +260,21 @@ void AppController::acceptReceive(const QString& sessionId) m_currentReceiveSenderAlias = session.sender.alias; m_totalReceiveSize = 0; m_receiveFileNames.clear(); + m_receiveFileStates.clear(); + m_receiveFinishedCount = 0; for (auto it = session.files.constBegin(); it != session.files.constEnd(); ++it) { m_totalReceiveSize += it->file.size; m_receiveFileNames.insert(it.key(), it->file.fileName); + QVariantMap file; + file[QStringLiteral("id")] = it.key(); + file[QStringLiteral("fileName")] = it.value().file.fileName; + file[QStringLiteral("size")] = it.value().file.size; + file[QStringLiteral("status")] = QStringLiteral("queue"); + m_receiveFileStates.append(file); } + m_receiveStartedAt = QDateTime::currentDateTime(); + m_receiveElapsed.start(); + emit receiveFileStatesChanged(); emit receivingChanged(); emit receiveProgressChanged(); @@ -433,17 +449,32 @@ void AppController::onUploadRequest(const QString& sessionId, const QString& fil m_sessions->updateReceiveProgress(sessionId, fileId, written); - if (sessionId == m_currentReceiveSessionId) { - m_currentReceiveFileName = m_receiveFileNames.value(fileId); - m_currentReceiveFileIndex++; - m_receivedSize += written; - if (m_totalReceiveSize > 0) { - m_receiveProgressValue = (static_cast(m_receivedSize) / m_totalReceiveSize) * 100.0; - } - emit receiveProgressChanged(); + // Accumulate session-level stats. No m_currentReceiveSessionId guard here: + // the server only accepts one active receive session, so this upload always + // belongs to the currently shown dialog. + m_currentReceiveFileName = m_receiveFileNames.value(fileId); + m_currentReceiveFileIndex++; + m_receivedSize += written; + if (m_totalReceiveSize > 0) { + m_receiveProgressValue = (static_cast(m_receivedSize) / m_totalReceiveSize) * 100.0; } + emit receiveProgressChanged(); if (written >= transfer.file.size) { + // Mark this file finished in the UI state *before* completing the session: + // completing the last file synchronously emits receiveSessionCompleted, which + // resets m_currentReceiveSessionId so we can no longer match the id afterwards. + for (int i = 0; i < m_receiveFileStates.size(); ++i) { + QVariantMap f = m_receiveFileStates[i].toMap(); + if (f.value(QStringLiteral("id")) == fileId) { + f[QStringLiteral("status")] = QStringLiteral("finished"); + m_receiveFileStates[i] = f; + m_receiveFinishedCount++; + break; + } + } + emit receiveFileStatesChanged(); + m_sessions->completeReceiveFile(sessionId, fileId); } } @@ -544,6 +575,83 @@ QString AppController::currentReceiveSenderAlias() const return m_currentReceiveSenderAlias; } +QVariantList AppController::receiveFileStates() const +{ + return m_receiveFileStates; +} + +int AppController::receiveFinishedCount() const +{ + return m_receiveFinishedCount; +} + +QVariantList AppController::sendFileStates() const +{ + return m_sendFileStates; +} + +int AppController::sendFinishedCountFiles() const +{ + return m_sendFinishedCount; +} + +double AppController::receiveThroughput() const +{ + // Bytes/second for the current receive session. Falls back to 0 before any data arrives. + if (!m_receiveElapsed.isValid() || m_receiveElapsed.nsecsElapsed() == 0 || m_receivedSize <= 0) { + return 0.0; + } + double seconds = m_receiveElapsed.nsecsElapsed() / 1e9; + if (seconds <= 0) { + return 0.0; + } + return m_receivedSize / seconds; +} + +double AppController::sendThroughput() const +{ + if (!m_sendElapsed.isValid() || m_sendElapsed.nsecsElapsed() == 0) { + return 0.0; + } + qint64 total = 0; + for (const auto& f : m_sendFileStates) { + total += f.toMap().value(QStringLiteral("size")).toLongLong(); + } + if (total <= 0) { + return 0.0; + } + double seconds = m_sendElapsed.nsecsElapsed() / 1e9; + if (seconds <= 0) { + return 0.0; + } + double transferred = (m_sendProgress / 100.0) * total; + return transferred / seconds; +} + +qint64 AppController::receiveTotalSize() const +{ + return m_totalReceiveSize; +} + +qint64 AppController::receivedSizeBytes() const +{ + return m_receivedSize; +} + +qint64 AppController::sendTotalBytes() const +{ + qint64 total = 0; + for (const auto& f : m_sendFileStates) { + total += f.toMap().value(QStringLiteral("size")).toLongLong(); + } + return total; +} + +qint64 AppController::sendTransferredBytes() const +{ + return static_cast((m_sendProgress / 100.0) * sendTotalBytes()); +} + QString AppController::currentSendFileName() const { if (m_currentFileIndex >= 0 && m_currentFileIndex < m_pendingSendPaths.size()) { @@ -689,6 +797,8 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis m_currentFileIndex = 0; m_sendProgress = 0.0; m_pinFirstAttempt = true; + m_sendFileStates.clear(); + m_sendFinishedCount = 0; qDebug() << "[AppController] sendFiles: device=" << deviceFingerprint << "files=" << filePaths.size(); @@ -708,6 +818,13 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis return; } + QVariantMap fstate; + fstate[QStringLiteral("id")] = QString::number(i); + fstate[QStringLiteral("fileName")] = info.fileName(); + fstate[QStringLiteral("size")] = info.size(); + fstate[QStringLiteral("status")] = QStringLiteral("queue"); + m_sendFileStates.append(fstate); + LocalSend::FileDto fileDto; fileDto.id = QString::number(i); fileDto.fileName = info.fileName(); @@ -728,6 +845,9 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis emit sendingChanged(); emit sendProgressChanged(); + m_sendStartedAt = QDateTime::currentDateTime(); + m_sendElapsed.start(); + emit sendFileStatesChanged(); LocalSend::PrepareUploadRequestDto request; request.info = buildRegisterDto(); @@ -802,6 +922,16 @@ void AppController::onPrepareUploadResponse(const LocalSend::PrepareUploadRespon sendNextFile(); } +void AppController::markSendFileSending(int index) +{ + if (index >= 0 && index < m_sendFileStates.size()) { + QVariantMap f = m_sendFileStates[index].toMap(); + f[QStringLiteral("status")] = QStringLiteral("sending"); + m_sendFileStates[index] = f; + emit sendFileStatesChanged(); + } +} + void AppController::onPrepareUploadError(const QString& error) { qWarning() << "[AppController] onPrepareUploadError:" << error; @@ -877,6 +1007,19 @@ void AppController::onUploadCompleted() m_sessions->completeSendFile(m_currentSendSessionId, m_currentSendFileId); + // Mark this file finished in the live state list. + QString finishedId = m_currentSendFileId; + for (int i = 0; i < m_sendFileStates.size(); ++i) { + QVariantMap f = m_sendFileStates[i].toMap(); + if (f.value(QStringLiteral("id")) == finishedId) { + f[QStringLiteral("status")] = QStringLiteral("finished"); + m_sendFinishedCount++; + m_sendFileStates[i] = f; + break; + } + } + emit sendFileStatesChanged(); + m_currentFileIndex++; if (m_currentFileIndex < m_pendingSendPaths.size()) { @@ -919,6 +1062,8 @@ void AppController::sendNextFile() return; } + markSendFileSending(m_currentFileIndex); + LocalSend::SendSession session = m_sessions->sendSession(m_currentSendSessionId); if (session.sessionId.isEmpty()) { qWarning() << "[AppController] Session not found:" << m_currentSendSessionId; @@ -991,6 +1136,7 @@ void AppController::resetSendState() m_currentSendDeviceFingerprint.clear(); m_currentFileIndex = 0; m_sendProgress = 0.0; + m_sendElapsed.invalidate(); emit sendingChanged(); emit sendProgressChanged(); } @@ -1004,8 +1150,9 @@ void AppController::resetReceiveState() m_totalReceiveFiles = 0; m_totalReceiveSize = 0; m_receivedSize = 0; - m_currentReceiveSenderAlias.clear(); + // Keep m_currentReceiveSenderAlias so the completed dialog still shows "From". m_receiveFileNames.clear(); + m_receiveElapsed.invalidate(); emit receivingChanged(); emit receiveProgressChanged(); } diff --git a/src/app/AppController.h b/src/app/AppController.h index ca88163..5eaa5ed 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include "LocalSendCore/DiscoveryManager.h" #include "LocalSendCore/HttpServer.h" #include "LocalSendCore/HttpClient.h" @@ -34,6 +36,16 @@ class AppController : public QObject Q_PROPERTY(int currentReceiveFileIndex READ currentReceiveFileIndex NOTIFY receiveProgressChanged) Q_PROPERTY(int totalReceiveFiles READ totalReceiveFiles NOTIFY receivingChanged) Q_PROPERTY(QString currentReceiveSenderAlias READ currentReceiveSenderAlias NOTIFY receivingChanged) + Q_PROPERTY(QVariantList receiveFileStates READ receiveFileStates NOTIFY receiveFileStatesChanged) + Q_PROPERTY(int receiveFinishedCount READ receiveFinishedCount NOTIFY receiveFileStatesChanged) + Q_PROPERTY(QVariantList sendFileStates READ sendFileStates NOTIFY sendFileStatesChanged) + Q_PROPERTY(int sendFinishedCount READ sendFinishedCountFiles NOTIFY sendFileStatesChanged) + Q_PROPERTY(double receiveThroughput READ receiveThroughput NOTIFY receiveProgressChanged) + Q_PROPERTY(qint64 receiveTotalSize READ receiveTotalSize NOTIFY receiveProgressChanged) + Q_PROPERTY(qint64 receivedSize READ receivedSizeBytes NOTIFY receiveProgressChanged) + Q_PROPERTY(double sendThroughput READ sendThroughput NOTIFY sendProgressChanged) + Q_PROPERTY(qint64 sendTotalBytes READ sendTotalBytes NOTIFY sendProgressChanged) + Q_PROPERTY(qint64 sendTransferredBytes READ sendTransferredBytes NOTIFY sendProgressChanged) Q_PROPERTY(QString deviceType READ deviceType WRITE setDeviceType NOTIFY deviceTypeChanged) Q_PROPERTY(bool https READ https WRITE setHttps NOTIFY httpsChanged) @@ -77,6 +89,16 @@ public: int currentReceiveFileIndex() const; int totalReceiveFiles() const; QString currentReceiveSenderAlias() const; + QVariantList receiveFileStates() const; + int receiveFinishedCount() const; + QVariantList sendFileStates() const; + int sendFinishedCountFiles() const; + double receiveThroughput() const; + qint64 receiveTotalSize() const; + qint64 receivedSizeBytes() const; + double sendThroughput() const; + qint64 sendTotalBytes() const; + qint64 sendTransferredBytes() const; QString deviceType() const; void setDeviceType(const QString& type); @@ -111,6 +133,8 @@ signals: void devicesChanged(); void serverRunningChanged(); void autoFinishChanged(); + void sendFileStatesChanged(); + void receiveFileStatesChanged(); void sendingChanged(); void sendProgressChanged(); void pendingFilesChanged(); @@ -173,6 +197,10 @@ private: int m_currentFileIndex = 0; double m_sendProgress = 0.0; bool m_pinFirstAttempt = true; + QVariantList m_sendFileStates; + int m_sendFinishedCount = 0; + QDateTime m_sendStartedAt; + QElapsedTimer m_sendElapsed; QString m_currentReceiveSessionId; double m_receiveProgressValue = 0.0; @@ -183,10 +211,15 @@ private: qint64 m_receivedSize = 0; QString m_currentReceiveSenderAlias; QMap m_receiveFileNames; + QVariantList m_receiveFileStates; + int m_receiveFinishedCount = 0; + QDateTime m_receiveStartedAt; + QElapsedTimer m_receiveElapsed; LocalSend::InfoDto buildInfoDto() const; QString generateUniqueFilePath(const QString& baseDir, const QString& fileName) const; void sendNextFile(); + void markSendFileSending(int index); LocalSend::RegisterDto buildRegisterDto() const; void resetSendState(); void resetReceiveState(); diff --git a/src/app/qml/main.qml b/src/app/qml/main.qml index 9f1ad2f..da53db8 100644 --- a/src/app/qml/main.qml +++ b/src/app/qml/main.qml @@ -15,6 +15,10 @@ ApplicationWindow { property string currentSenderAlias: "" property string currentSenderIp: "" property string currentMessage: "" + property double displayReceiveThroughput: 0 + property string displayReceiveEta: "-" + property double displaySendThroughput: 0 + property string displaySendEta: "-" function getDeviceTypeIcon(deviceType) { switch (deviceType) { @@ -175,42 +179,118 @@ ApplicationWindow { modal: true closePolicy: Popup.NoAutoClose title: succeeded ? qsTr("Receive Complete") : qsTr("Receiving Files") + width: 520 + implicitWidth: 520 property bool succeeded: false ColumnLayout { spacing: 12 - + width: parent.width + Label { text: qsTr("From: %1").arg(appController.currentReceiveSenderAlias) font.bold: true } - - Label { - visible: !receiveProgressDialog.succeeded - text: appController.currentReceiveFileName - ? qsTr("%1 (%2/%3)").arg(appController.currentReceiveFileName) - .arg(appController.currentReceiveFileIndex) - .arg(appController.totalReceiveFiles) - : qsTr("Waiting for data...") - elide: Text.ElideMiddle - Layout.maximumWidth: 400 - } - + ProgressBar { Layout.fillWidth: true from: 0 to: 100 value: receiveProgressDialog.succeeded ? 100 : appController.receiveProgress + indeterminate: !receiveProgressDialog.succeeded && appController.receiveProgress === 0 } - + + RowLayout { + Layout.fillWidth: true + + Label { + text: receiveProgressDialog.succeeded + ? qsTr("Files received successfully.") + : qsTr("%1% complete").arg(Math.round(appController.receiveProgress)) + Layout.fillWidth: true + } + Label { + text: qsTr("%1 / %2 files") + .arg(appController.receiveFinishedCount) + .arg(appController.receiveFileStates.length) + color: palette.mid + } + } + + RowLayout { + Layout.fillWidth: true + visible: !receiveProgressDialog.succeeded + + Label { + text: qsTr("Speed: %1").arg(formatThroughput(displayReceiveThroughput)) + color: palette.mid + } + Item { Layout.fillWidth: true } + Label { + text: qsTr("Remaining: %1").arg(displayReceiveEta) + color: palette.mid + } + } + Label { - text: receiveProgressDialog.succeeded - ? qsTr("Files received successfully.") - : qsTr("%1% complete").arg(Math.round(appController.receiveProgress)) - color: receiveProgressDialog.succeeded ? palette.mid : palette.mid + visible: !receiveProgressDialog.succeeded && appController.currentReceiveFileName.length > 0 + text: qsTr("Current: %1").arg(appController.currentReceiveFileName) + color: palette.mid + elide: Text.ElideMiddle + Layout.fillWidth: true + font.pixelSize: 12 } - + + Rectangle { + visible: receiveProgressDialog.succeeded || appController.receiveFileStates.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: Math.min(260, appController.receiveFileStates.length * 26 + 8) + color: palette.base + border.color: palette.mid + radius: 6 + + ListView { + anchors.fill: parent + anchors.margins: 4 + model: appController.receiveFileStates + spacing: 2 + clip: true + + delegate: RowLayout { + width: ListView.view.width + height: 22 + spacing: 8 + + Label { + text: modelData.fileName + Layout.fillWidth: true + color: palette.text + elide: Text.ElideRight + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + } + Label { + text: formatSize(modelData.size) + Layout.preferredWidth: 80 + color: palette.mid + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + } + Label { + text: modelData.status === "finished" ? qsTr("Done") + : modelData.status === "sending" ? qsTr("Active") + : qsTr("Waiting") + Layout.preferredWidth: 60 + color: modelData.status === "finished" ? "green" + : modelData.status === "sending" ? palette.text + : palette.mid + font.pixelSize: 12 + } + } + } + } + Label { visible: receiveProgressDialog.succeeded text: qsTr("Saved to: %1").arg(appController.downloadPath) @@ -319,35 +399,108 @@ ApplicationWindow { modal: true closePolicy: Popup.NoAutoClose title: succeeded ? qsTr("Send Complete") : qsTr("Sending Files") + width: 520 + implicitWidth: 520 property bool succeeded: false ColumnLayout { spacing: 12 - + width: parent.width + Label { - visible: !sendProgressDialog.succeeded - text: appController.currentSendFileName - ? qsTr("%1 (%2/%3)").arg(appController.currentSendFileName) - .arg(appController.currentSendFileIndex) - .arg(appController.totalSendFiles) - : qsTr("Preparing...") + visible: !sendProgressDialog.succeeded && appController.currentSendFileName.length > 0 + text: qsTr("Current: %1").arg(appController.currentSendFileName) elide: Text.ElideMiddle - Layout.maximumWidth: 400 + Layout.fillWidth: true } - + ProgressBar { Layout.fillWidth: true from: 0 to: 100 value: sendProgressDialog.succeeded ? 100 : appController.sendProgress } - - Label { - text: sendProgressDialog.succeeded - ? qsTr("Files sent successfully.") - : qsTr("%1% complete").arg(Math.round(appController.sendProgress)) - color: palette.mid + + RowLayout { + Layout.fillWidth: true + + Label { + text: sendProgressDialog.succeeded + ? qsTr("Files sent successfully.") + : qsTr("%1% complete").arg(Math.round(appController.sendProgress)) + Layout.fillWidth: true + } + Label { + text: qsTr("%1 / %2 files") + .arg(appController.sendFinishedCount) + .arg(appController.sendFileStates.length) + color: palette.mid + } + } + + RowLayout { + Layout.fillWidth: true + visible: !sendProgressDialog.succeeded + + Label { + text: qsTr("Speed: %1").arg(formatThroughput(displaySendThroughput)) + color: palette.mid + } + Item { Layout.fillWidth: true } + Label { + text: qsTr("Remaining: %1").arg(displaySendEta) + color: palette.mid + } + } + + Rectangle { + visible: sendProgressDialog.succeeded || appController.sendFileStates.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: Math.min(260, appController.sendFileStates.length * 26 + 8) + color: palette.base + border.color: palette.mid + radius: 6 + + ListView { + anchors.fill: parent + anchors.margins: 4 + model: appController.sendFileStates + spacing: 2 + clip: true + + delegate: RowLayout { + width: ListView.view.width + height: 22 + spacing: 8 + + Label { + text: modelData.fileName + Layout.fillWidth: true + color: palette.text + elide: Text.ElideRight + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + } + Label { + text: formatSize(modelData.size) + Layout.preferredWidth: 80 + color: palette.mid + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + } + Label { + text: modelData.status === "finished" ? qsTr("Done") + : modelData.status === "sending" ? qsTr("Active") + : qsTr("Waiting") + Layout.preferredWidth: 60 + color: modelData.status === "finished" ? "green" + : modelData.status === "sending" ? palette.text + : palette.mid + font.pixelSize: 12 + } + } + } } } @@ -634,6 +787,59 @@ ApplicationWindow { return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) } + function formatThroughput(bytesPerSec) { + if (bytesPerSec <= 0) return "-" + var sec = bytesPerSec + if (sec < 1024) return Math.round(sec) + " B/s" + if (sec < 1024 * 1024) return (sec / 1024).toFixed(1) + " KB/s" + return (sec / (1024 * 1024)).toFixed(1) + " MB/s" + } + + function formatEta(remainingSec) { + if (!isFinite(remainingSec) || remainingSec <= 0) return "-" + var total = Math.ceil(remainingSec) + var h = Math.floor(total / 3600) + var m = Math.floor((total % 3600) / 60) + var s = total % 60 + if (h > 0) return h + "h " + m + "m" + if (m > 0) return m + "m " + s + "s" + return s + "s" + } + + Timer { + id: receiveRateTimer + interval: 1000 + repeat: true + running: receiveProgressDialog.visible + onTriggered: { + displayReceiveThroughput = appController.receiveThroughput + var total = appController.totalReceiveSize + var received = appController.receivedSize + if (displayReceiveThroughput > 0 && total > received) { + displayReceiveEta = formatEta((total - received) / displayReceiveThroughput) + } else { + displayReceiveEta = "-" + } + } + } + + Timer { + id: sendRateTimer + interval: 1000 + repeat: true + running: sendProgressDialog.visible + onTriggered: { + displaySendThroughput = appController.sendThroughput + var total = appController.sendTotalBytes + var sent = appController.sendTransferredBytes + if (displaySendThroughput > 0 && total > sent) { + displaySendEta = formatEta((total - sent) / displaySendThroughput) + } else { + displaySendEta = "-" + } + } + } + Component { id: homePageComponent Page { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ac6532c..1e9f751 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,3 +21,7 @@ add_test(NAME TestSessionManager COMMAND TestSessionManager) add_executable(TestDtoTypesExtended core/TestDtoTypesExtended.cpp) target_link_libraries(TestDtoTypesExtended PRIVATE LocalSendCore Qt6::Test) add_test(NAME TestDtoTypesExtended COMMAND TestDtoTypesExtended) + +add_executable(TestHttpClientMtls core/TestHttpClientMtls.cpp) +target_link_libraries(TestHttpClientMtls PRIVATE LocalSendCore Qt6::Test) +add_test(NAME TestHttpClientMtls COMMAND TestHttpClientMtls) diff --git a/tests/core/TestHttpClientMtls.cpp b/tests/core/TestHttpClientMtls.cpp new file mode 100644 index 0000000..fb6c34e --- /dev/null +++ b/tests/core/TestHttpClientMtls.cpp @@ -0,0 +1,167 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// The modern LocalSend protocol server (the Rust implementation) requires +// mutual TLS: it only accepts a sender that presents a valid client +// certificate during the TLS handshake. These tests spin up a QSslServer with +// mandatory client-certificate verification and assert that LocalSend's +// HttpClient can complete the handshake *only when* its SSL configuration +// carries the device certificate (regression test for +// "tlsv13 alert certificate required" when sending files). +class TestHttpClientMtls : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void cleanupTestCase(); + void testSslConfigurationCarriesDeviceIdentity(); + void testGetInfoSucceedsWithClientCertificate(); + void testGetInfoFailsWithoutClientCertificate(); + +private: + // Starts a QHttpServer over TLS that REQUIRES a client certificate. + // The trust store is seeded with our device certificate, so only a client + // presenting that certificate passes. Returns the listening port. + quint16 startMtlsServer(QHttpServer& http, QSslServer& ssl, + const LocalSend::SecurityContext& sec); + + QString m_configDir; + LocalSend::SecurityContext* m_sec = nullptr; +}; + +void TestHttpClientMtls::initTestCase() +{ + // Isolated storage in the (writable) build directory so the test never + // touches the real app certificate or depends on the user's HOME. + m_configDir = QDir::currentPath() + QStringLiteral("/.mtls-test-cfg"); + QDir().mkpath(m_configDir); + qputenv("XDG_CONFIG_HOME", m_configDir.toUtf8()); + + m_sec = new LocalSend::SecurityContext(this); + m_sec->initialize(); +} + +void TestHttpClientMtls::cleanupTestCase() +{ + delete m_sec; + m_sec = nullptr; + QDir(m_configDir).removeRecursively(); +} + +void TestHttpClientMtls::testSslConfigurationCarriesDeviceIdentity() +{ + QVERIFY(m_sec); + QSslConfiguration config = m_sec->sslConfiguration(); + QVERIFY(!config.isNull()); + QVERIFY(!config.localCertificate().isNull()); + QVERIFY(!m_sec->privateKey().isNull()); + // The config doubles as the client identity: it must carry the device + // certificate that the handshake presents to the receiver. + QCOMPARE(config.localCertificate().digest(QCryptographicHash::Sha256), + m_sec->certificate().digest(QCryptographicHash::Sha256)); +} + +void TestHttpClientMtls::testGetInfoSucceedsWithClientCertificate() +{ + QVERIFY(m_sec); + QVERIFY(!m_sec->sslConfiguration().isNull()); + + QHttpServer http; + QSslServer ssl; + quint16 port = startMtlsServer(http, ssl, *m_sec); + QVERIFY(port != 0); + + LocalSend::HttpClient client; + // The app wires the same security context into the client (AppController). + client.setSslConfiguration(m_sec->sslConfiguration()); + + LocalSend::Device device(QStringLiteral("127.0.0.1"), port); + device.protocol = LocalSend::ProtocolType::Https; + + bool gotInfo = false; + bool gotError = false; + QObject::connect(&client, &LocalSend::HttpClient::infoReceived, + [&](const LocalSend::InfoDto&) { gotInfo = true; }); + QObject::connect(&client, &LocalSend::HttpClient::infoError, + [&](const QString&) { gotError = true; }); + + client.getInfo(device); + QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000); + + QVERIFY(!gotError); + QVERIFY(gotInfo); +} + +void TestHttpClientMtls::testGetInfoFailsWithoutClientCertificate() +{ + QVERIFY(m_sec); + + QHttpServer http; + QSslServer ssl; + quint16 port = startMtlsServer(http, ssl, *m_sec); + QVERIFY(port != 0); + + // NO client SSL configuration: like the app before the fix, the client + // does not present a certificate and the handshake must be rejected. + LocalSend::HttpClient client; + + LocalSend::Device device(QStringLiteral("127.0.0.1"), port); + device.protocol = LocalSend::ProtocolType::Https; + + bool gotInfo = false; + bool gotError = false; + QObject::connect(&client, &LocalSend::HttpClient::infoReceived, + [&](const LocalSend::InfoDto&) { gotInfo = true; }); + QObject::connect(&client, &LocalSend::HttpClient::infoError, + [&](const QString&) { gotError = true; }); + + client.getInfo(device); + QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000); + + QVERIFY(!gotInfo); + QVERIFY(gotError); +} + +quint16 TestHttpClientMtls::startMtlsServer(QHttpServer& http, QSslServer& ssl, + const LocalSend::SecurityContext& sec) +{ + http.route(QString::fromLatin1(LocalSend::ApiRoute::INFO), QHttpServerRequest::Method::Get, + [](const QHttpServerRequest&) { + QJsonObject o; + o[QStringLiteral("alias")] = QStringLiteral("test-receiver"); + o[QStringLiteral("version")] = QStringLiteral("1.0"); + o[QStringLiteral("fingerprint")] = QStringLiteral("AABB"); + return QHttpServerResponse(QJsonDocument(o).toJson(QJsonDocument::Compact), + QHttpServerResponse::StatusCode::Ok); + }); + + QSslConfiguration serverSsl; + serverSsl.setLocalCertificate(sec.certificate()); + serverSsl.setPrivateKey(sec.privateKey()); + serverSsl.setPeerVerifyMode(QSslSocket::VerifyPeer); // mandatory client cert + serverSsl.setCaCertificates({ sec.certificate() }); // trust our device cert + ssl.setSslConfiguration(serverSsl); + + if (!ssl.listen(QHostAddress::LocalHost, 0)) { + qWarning() << "Failed to listen for MTLS test server"; + return 0; + } + http.bind(&ssl); + return ssl.serverPort(); +} + +QTEST_MAIN(TestHttpClientMtls) +#include "TestHttpClientMtls.moc" \ No newline at end of file