|
| 1 | +/* |
| 2 | + * Copyright (C) by Thomas Müller |
| 3 | + * |
| 4 | + * This program is free software; you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU General Public License as published by |
| 6 | + * the Free Software Foundation; either version 2 of the License, or |
| 7 | + * (at your option) any later version. |
| 8 | + * |
| 9 | + * This program is distributed in the hope that it will be useful, but |
| 10 | + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY |
| 11 | + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License |
| 12 | + * for more details. |
| 13 | + */ |
| 14 | + |
| 15 | +#include <QtTest> |
| 16 | +#include <QLocalSocket> |
| 17 | +#include <QSignalSpy> |
| 18 | +#include <QStandardPaths> |
| 19 | +#include <QTemporaryDir> |
| 20 | + |
| 21 | +#include <chrono> |
| 22 | +using namespace std::chrono_literals; |
| 23 | + |
| 24 | +#include "socketapi/socketapi.h" |
| 25 | +#include "folderman.h" |
| 26 | +#include "accountmanager.h" |
| 27 | + |
| 28 | +#include "testutils/syncenginetestutils.h" |
| 29 | +#include "testutils/testutils.h" |
| 30 | + |
| 31 | +using namespace OCC; |
| 32 | + |
| 33 | +// A minimal fake reply that emits a Depth:0 PROPFIND multistatus response |
| 34 | +// with status 207 and the correct content-type, suitable for private link lookups. |
| 35 | +class FakeMultistatReply : public FakeReply |
| 36 | +{ |
| 37 | + Q_OBJECT |
| 38 | +public: |
| 39 | + QByteArray _body; |
| 40 | + |
| 41 | + FakeMultistatReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, |
| 42 | + const QByteArray &body, QObject *parent) |
| 43 | + : FakeReply(parent), _body(body) |
| 44 | + { |
| 45 | + setRequest(request); |
| 46 | + setUrl(request.url()); |
| 47 | + setOperation(op); |
| 48 | + open(QIODevice::ReadOnly); |
| 49 | + QTimer::singleShot(10ms, this, &FakeMultistatReply::respond); |
| 50 | + } |
| 51 | + |
| 52 | + void respond() |
| 53 | + { |
| 54 | + setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 207); |
| 55 | + setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/xml; charset=utf-8")); |
| 56 | + setHeader(QNetworkRequest::ContentLengthHeader, _body.size()); |
| 57 | + Q_EMIT metaDataChanged(); |
| 58 | + Q_EMIT readyRead(); |
| 59 | + checkedFinished(); |
| 60 | + } |
| 61 | + |
| 62 | + qint64 readData(char *buf, qint64 max) override |
| 63 | + { |
| 64 | + max = qMin<qint64>(max, _body.size()); |
| 65 | + std::copy(_body.cbegin(), _body.cbegin() + max, buf); |
| 66 | + _body.remove(0, static_cast<int>(max)); |
| 67 | + return max; |
| 68 | + } |
| 69 | + |
| 70 | + qint64 bytesAvailable() const override { return _body.size(); } |
| 71 | +}; |
| 72 | + |
| 73 | +class TestSocketApi : public QObject |
| 74 | +{ |
| 75 | + Q_OBJECT |
| 76 | + |
| 77 | + // Path of the folder registered during a test (reset by cleanup()). |
| 78 | + QString _registeredFolderPath; |
| 79 | + |
| 80 | + // Compute the socket path the same way guiutility_unix.cpp does. |
| 81 | + // Utility::socketApiSocketPath() provides the same logic, but it lives in the |
| 82 | + // GUI library and is not exposed as a public method of SocketApi, so the |
| 83 | + // computation is duplicated here rather than introducing an extra dependency. |
| 84 | + static QString socketApiPath() |
| 85 | + { |
| 86 | + return QStringLiteral("%1/ownCloud/socket") |
| 87 | + .arg(QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation)); |
| 88 | + } |
| 89 | + |
| 90 | + // Connect to the SocketApi's server and wait for the connection to be accepted |
| 91 | + // (slotNewConnection fired). Returns the connected socket or nullptr on failure. |
| 92 | + static QLocalSocket *connectAndWaitForListener() |
| 93 | + { |
| 94 | + const QString path = socketApiPath(); |
| 95 | + auto *sock = new QLocalSocket; |
| 96 | + sock->connectToServer(path); |
| 97 | + if (!sock->waitForConnected(3000)) { |
| 98 | + qWarning() << "Failed to connect to socket" << path |
| 99 | + << "error:" << sock->errorString(); |
| 100 | + delete sock; |
| 101 | + return nullptr; |
| 102 | + } |
| 103 | + // Give the server side a chance to run slotNewConnection and |
| 104 | + // insert the listener into _listeners. |
| 105 | + QCoreApplication::processEvents(); |
| 106 | + return sock; |
| 107 | + } |
| 108 | + |
| 109 | +private Q_SLOTS: |
| 110 | + void initTestCase() |
| 111 | + { |
| 112 | + // Ensure FolderMan singleton exists before any test |
| 113 | + TestUtils::folderMan(); |
| 114 | + } |
| 115 | + |
| 116 | + void cleanup() |
| 117 | + { |
| 118 | + // Remove any folder registered during the test to avoid polluting later tests. |
| 119 | + if (!_registeredFolderPath.isEmpty()) { |
| 120 | + Folder *folder = TestUtils::folderMan()->folderForPath(_registeredFolderPath); |
| 121 | + if (folder) { |
| 122 | + TestUtils::folderMan()->removeFolderFromGui(folder); |
| 123 | + } |
| 124 | + _registeredFolderPath.clear(); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + void testGetPrivateLinkSendsUrlOverSocket() |
| 129 | + { |
| 130 | + // ----- Set up a paused sync folder so no sync fires ----- |
| 131 | + |
| 132 | + // Create a real directory on disk as the local sync root |
| 133 | + const QTemporaryDir tempDir = TestUtils::createTempDir(); |
| 134 | + QVERIFY(tempDir.isValid()); |
| 135 | + // Ensure trailing slash (FolderDefinition normalises it, but be explicit) |
| 136 | + const QString localPath = QDir::cleanPath(tempDir.path()) + QLatin1Char('/'); |
| 137 | + |
| 138 | + // createDummyAccount creates an account with a FakeAM (empty FileInfo) |
| 139 | + auto accountState = createDummyAccount(); |
| 140 | + Account *account = accountState->account(); |
| 141 | + |
| 142 | + // Enable private links capability |
| 143 | + auto cap = TestUtils::testCapabilities(); |
| 144 | + cap[QStringLiteral("files")] = QVariantMap{{QStringLiteral("privateLinks"), true}}; |
| 145 | + account->setCapabilities({account->url(), cap}); |
| 146 | + |
| 147 | + // Set a server override: intercept the Depth:0 PROPFIND that fetchPrivateLinkUrl issues |
| 148 | + // and reply with a 207 multistatus containing the private link URL. |
| 149 | + const QString expectedUrl = QStringLiteral("https://example.com/f/abc123"); |
| 150 | + auto *fakeAm = dynamic_cast<FakeAM *>(account->accessManager()); |
| 151 | + QVERIFY(fakeAm); |
| 152 | + fakeAm->setOverride([expectedUrl](QNetworkAccessManager::Operation op, |
| 153 | + const QNetworkRequest &req, |
| 154 | + QIODevice *) -> QNetworkReply * { |
| 155 | + if (op != QNetworkAccessManager::CustomOperation) |
| 156 | + return nullptr; |
| 157 | + if (req.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray() |
| 158 | + != QByteArrayLiteral("PROPFIND")) |
| 159 | + return nullptr; |
| 160 | + if (req.rawHeader(QByteArrayLiteral("Depth")) != QByteArrayLiteral("0")) |
| 161 | + return nullptr; |
| 162 | + |
| 163 | + const QString path = req.url().path(); |
| 164 | + const QByteArray body = |
| 165 | + QByteArrayLiteral("<?xml version=\"1.0\"?>" |
| 166 | + "<d:multistatus xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\">" |
| 167 | + "<d:response><d:href>") + path.toUtf8() |
| 168 | + + QByteArrayLiteral("</d:href><d:propstat><d:prop>" |
| 169 | + "<oc:privatelink>") + expectedUrl.toUtf8() |
| 170 | + + QByteArrayLiteral("</oc:privatelink>" |
| 171 | + "</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>" |
| 172 | + "</d:response></d:multistatus>"); |
| 173 | + return new FakeMultistatReply(op, req, body, nullptr); |
| 174 | + }); |
| 175 | + |
| 176 | + // Register the directory with FolderMan (paused so no sync fires) |
| 177 | + auto def = TestUtils::createDummyFolderDefinition(account, localPath); |
| 178 | + def.setPaused(true); |
| 179 | + Folder *folder = TestUtils::folderMan()->addFolder(accountState.get(), def); |
| 180 | + QVERIFY(folder); |
| 181 | + // Track the path so cleanup() can remove it after the test. |
| 182 | + _registeredFolderPath = localPath; |
| 183 | + |
| 184 | + // ----- Start SocketApi server ----- |
| 185 | + SocketApi api; |
| 186 | + api.startShellIntegration(); |
| 187 | + |
| 188 | + // Connect a client socket (triggers slotNewConnection → listener registered) |
| 189 | + std::unique_ptr<QLocalSocket> clientSocket(connectAndWaitForListener()); |
| 190 | + QVERIFY(clientSocket); |
| 191 | + |
| 192 | + // Drain any broadcast messages sent on connect (e.g. REGISTER_PATH) |
| 193 | + QCoreApplication::processEvents(); |
| 194 | + clientSocket->readAll(); |
| 195 | + |
| 196 | + // ----- Send the GET_PRIVATE_LINK command ----- |
| 197 | + // Use the sync-folder root as the localFile argument. |
| 198 | + // isSyncFolder() is true for the root path, so the journal record |
| 199 | + // check is bypassed. |
| 200 | + const QString command = |
| 201 | + QStringLiteral("GET_PRIVATE_LINK:") + localPath + QLatin1Char('\n'); |
| 202 | + clientSocket->write(command.toUtf8()); |
| 203 | + QVERIFY(clientSocket->flush()); |
| 204 | + |
| 205 | + // ----- Wait for the PRIVATE_LINK response ----- |
| 206 | + QSignalSpy readySpy(clientSocket.get(), &QLocalSocket::readyRead); |
| 207 | + QVERIFY(readySpy.wait(5000)); |
| 208 | + |
| 209 | + // Collect all data (may arrive in chunks) |
| 210 | + QByteArray received; |
| 211 | + for (int retries = 5; retries > 0; --retries) { |
| 212 | + received += clientSocket->readAll(); |
| 213 | + if (received.contains("PRIVATE_LINK:")) |
| 214 | + break; |
| 215 | + if (!readySpy.wait(1000)) |
| 216 | + break; |
| 217 | + } |
| 218 | + |
| 219 | + const QByteArray expectedResponse = |
| 220 | + QByteArrayLiteral("PRIVATE_LINK:") + expectedUrl.toUtf8() + '\n'; |
| 221 | + QVERIFY2(received.contains(expectedResponse), |
| 222 | + qPrintable(QStringLiteral("Expected '%1' in received data '%2'") |
| 223 | + .arg(QString::fromUtf8(expectedResponse), QString::fromUtf8(received)))); |
| 224 | + } |
| 225 | + |
| 226 | + void testGetPrivateLinkNoResponseWhenFileNotSynced() |
| 227 | + { |
| 228 | + // The path /tmp/not_a_synced_file_testsocketapi.txt is not under any registered |
| 229 | + // sync root → command_GET_PRIVATE_LINK should return early with no reply. |
| 230 | + |
| 231 | + SocketApi api; |
| 232 | + api.startShellIntegration(); |
| 233 | + |
| 234 | + std::unique_ptr<QLocalSocket> clientSocket(connectAndWaitForListener()); |
| 235 | + QVERIFY(clientSocket); |
| 236 | + |
| 237 | + // Drain broadcast messages |
| 238 | + QCoreApplication::processEvents(); |
| 239 | + clientSocket->readAll(); |
| 240 | + |
| 241 | + const QString command = |
| 242 | + QStringLiteral("GET_PRIVATE_LINK:/tmp/not_a_synced_file_testsocketapi.txt\n"); |
| 243 | + clientSocket->write(command.toUtf8()); |
| 244 | + QVERIFY(clientSocket->flush()); |
| 245 | + |
| 246 | + // Wait briefly — there should be no PRIVATE_LINK message. |
| 247 | + // Using QTest::qWait (unconditional) rather than QSignalSpy::wait so the |
| 248 | + // assertion is always evaluated and cannot pass vacuously when no data arrives. |
| 249 | + QTest::qWait(500); |
| 250 | + const QByteArray received = clientSocket->readAll(); |
| 251 | + QVERIFY2(!received.contains("PRIVATE_LINK:"), |
| 252 | + qPrintable(QStringLiteral("Expected no PRIVATE_LINK response for unsynced file, got: ") + received)); |
| 253 | + } |
| 254 | +}; |
| 255 | + |
| 256 | +QTEST_GUILESS_MAIN(TestSocketApi) |
| 257 | +#include "testsocketapi.moc" |
0 commit comments