libreoffice-online/test/WopiTestServer.hpp

580 lines
22 KiB
C++
Raw Normal View History

/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4; fill-column: 100 -*- */
/*
* Copyright the Collabora Online contributors.
*
* SPDX-License-Identifier: MPL-2.0
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
#pragma once
#include "Protocol.hpp"
#include "HttpRequest.hpp"
#include "helpers.hpp"
#include "Log.hpp"
#include "Unit.hpp"
#include "Util.hpp"
#include "UnitWSDClient.hpp"
#include "StringVector.hpp"
#include "lokassert.hpp"
#include <Poco/JSON/Object.h>
#include <Poco/URI.h>
#include <Poco/Util/LayeredConfiguration.h>
#include <cctype>
#include <sstream>
#include <vector>
namespace Poco
{
namespace Net
{
class HTTPRequest;
}
} // namespace Poco
/// Simulates a WOPI server for testing purposes.
/// Currently only serves one file contents.
/// Furthermore, the file URI doesn't contain the
/// real filename (in most tests), instead filenames
/// 1 to 9 are considered special.
class WopiTestServer : public UnitWSDClient
{
private:
enum class COOLStatusCode
{
DocChanged = 1010
};
/// Content of the file.
std::string _fileContent;
/// The filename. TODO: Support multiple ones.
std::string _filename;
/// Last modified time of the file
std::chrono::system_clock::time_point _fileLastModifiedTime;
/// The number of CheckFileInfo invocations.
std::size_t _countCheckFileInfo;
/// The number of GetFile invocations.
std::size_t _countGetFile;
/// The number of rename invocations.
std::size_t _countPutRelative;
/// The number of upload invocations.
std::size_t _countPutFile;
/// The default filename when only content is given.
static constexpr auto DefaultFilename = "hello.txt";
protected:
const std::string& getFileContent() const { return _fileContent; }
/// Sets the file content to a given value and update the last file modified time
void setFileContent(const std::string& fileContent)
{
LOG_TST("setFileContent: [" << COOLProtocol::getAbbreviatedMessage(fileContent ) << ']');
_fileContent = fileContent;
_fileLastModifiedTime = std::chrono::system_clock::now();
}
const std::chrono::system_clock::time_point& getFileLastModifiedTime() const
{
return _fileLastModifiedTime;
}
WopiTestServer(const std::string& name, const std::string& filenameOrContents = "Hello, world")
: UnitWSDClient(name)
, _filename(DefaultFilename)
, _countCheckFileInfo(0)
, _countGetFile(0)
, _countPutRelative(0)
, _countPutFile(0)
{
LOG_TST("WopiTestServer created for [" << getTestname() << ']');
// Read the document data and store as string in memory.
const auto data = helpers::readDataFromFile(filenameOrContents);
if (!data.empty())
{
// That was a filename, set its contents.
LOG_TST("WopiTestServer created with " << data.size() << " bytes from file ["
<< filenameOrContents << "]");
_filename = filenameOrContents; // Capture the real filename.
setFileContent(std::string(data.begin(), data.end()));
}
else
{
// Not a valid filename, assume it's some data.
LOG_TST("WopiTestServer created with " << filenameOrContents.size()
<< " bytes from data.");
setFileContent(filenameOrContents);
}
}
std::size_t getCountCheckFileInfo() const { return _countCheckFileInfo; }
void resetCountCheckFileInfo()
{
LOG_TST("Resetting countCheckFileInfo [" << _countCheckFileInfo << "] to zero");
_countCheckFileInfo = 0;
}
std::size_t getCountGetFile() const { return _countGetFile; }
void resetCountGetFile()
{
LOG_TST("Resetting countGetFile [" << _countGetFile << "] to zero");
_countGetFile = 0;
}
std::size_t getCountPutRelative() const { return _countPutRelative; }
void resetCountPutRelative()
{
LOG_TST("Resetting countPutRelative [" << _countPutRelative << "] to zero");
_countPutRelative = 0;
}
std::size_t getCountPutFile() const { return _countPutFile; }
void resetCountPutFile()
{
LOG_TST("Resetting countPutFile [" << _countPutFile << "] to zero");
_countPutFile = 0;
}
virtual std::unique_ptr<http::Response>
assertCheckFileInfoRequest(const Poco::Net::HTTPRequest& /*request*/)
{
return nullptr; // Success.
}
virtual std::unique_ptr<http::Response>
assertGetFileRequest(const Poco::Net::HTTPRequest& /*request*/)
{
return nullptr; // Success.
}
/// Assert the PutFile request is valid and optionally return a response.
virtual std::unique_ptr<http::Response>
assertPutFileRequest(const Poco::Net::HTTPRequest& /*request*/)
{
return nullptr; // Success.
}
virtual void assertPutRelativeFileRequest(const Poco::Net::HTTPRequest& /*request*/)
{
}
virtual void assertRenameFileRequest(const Poco::Net::HTTPRequest& /*request*/)
{
}
/// Called when the server receives a Lock or Unlock request.
virtual std::unique_ptr<http::Response>
assertLockRequest(const Poco::Net::HTTPRequest& /*request*/)
{
return nullptr; // Success.
}
/// Given a URI, returns the filename.
///FIXME: this should be remove when we support multiple files properly.
virtual std::string getFilename(const Poco::URI& uri) const
{
std::string filename = extractFilenameFromWopiUri(uri.getPath());
// Note: This is a fake implementation.
if (filename == "3")
{
// Test '%' in the filename.
//FIXME: pass this in the URI.
return "he%llo.txt";
}
if (filename.size() == 1 && std::isdigit(filename[0]))
{
const auto number = std::stoi(filename);
if (number >= 1 && number <= 9)
{
// Fake filename, depends on implicit filename.
return DefaultFilename;
}
}
// Return the filename given in the URI.
return filename;
}
/// Returns the virtual root-path that we serve.
static const std::string& getURIRootPath()
{
static const std::string RootPath = "/wopi/files/";
return RootPath;
}
/// Given a wopi URI, extracts the filename.
static std::string extractFilenameFromWopiUri(const std::string& uriPath)
{
if (uriPath.starts_with(getURIRootPath()))
{
const auto first = getURIRootPath().size();
const auto it = uriPath.find_first_of('/', first);
return uriPath.substr(first, it);
}
return std::string();
}
/// Returns true iff @uriPath is a Wopi path but not to the contents.
static bool isWopiInfoRequest(const std::string& uriPath)
{
return uriPath.starts_with(getURIRootPath()) && !uriPath.ends_with("/contents");
}
/// Returns true iff @uriPath is a Wopi path to the contents of a file.
static bool isWopiContentRequest(const std::string& uriPath)
{
return uriPath.starts_with(getURIRootPath()) && uriPath.ends_with("/contents");
}
void configure(Poco::Util::LayeredConfiguration& config) override
{
UnitWSD::configure(config);
// we're still internally confused as to https vs. http in places.
config.setBool("storage.ssl.as_scheme", false);
// Reset to default.
config.setBool("storage.wopi.is_legacy_server", false);
}
/// Returns the default CheckFileInfo json.
Poco::JSON::Object::Ptr getDefaultCheckFileInfoPayload(const Poco::URI& uri)
{
Poco::JSON::Object::Ptr fileInfo = new Poco::JSON::Object();
fileInfo->set("BaseFileName", getFilename(uri));
fileInfo->set("Size", getFileContent().size());
fileInfo->set("Version", "1.0");
fileInfo->set("OwnerId", "tuser");
fileInfo->set("UserId", "tuser");
fileInfo->set("UserFriendlyName", "Test User");
fileInfo->set("UserCanWrite", "true");
fileInfo->set("PostMessageOrigin", "localhost");
fileInfo->set("LastModifiedTime",
Util::getIso8601FracformatTime(getFileLastModifiedTime()));
fileInfo->set("EnableOwnerTermination", "true");
fileInfo->set("SupportsLocks", "false");
return fileInfo;
}
/// Returns the default CheckFileInfo json.
Poco::JSON::Object::Ptr getDefaultCheckFileInfoPayload(const std::string& uri)
{
return getDefaultCheckFileInfoPayload(Poco::URI(uri));
}
/// Handles WOPI CheckFileInfo requests.
virtual bool handleCheckFileInfoRequest(const Poco::Net::HTTPRequest& request,
std::shared_ptr<StreamSocket>& socket)
{
std::unique_ptr<http::Response> httpResponse = assertCheckFileInfoRequest(request);
if (!httpResponse)
httpResponse = std::make_unique<http::Response>(http::StatusCode::OK);
if (httpResponse->statusLine().statusCategory() ==
http::StatusLine::StatusCodeClass::Successful)
{
Poco::JSON::Object::Ptr fileInfo = getDefaultCheckFileInfoPayload(request.getURI());
configCheckFileInfo(request, fileInfo);
std::ostringstream jsonStream;
fileInfo->stringify(jsonStream);
const std::string json = jsonStream.str();
LOG_TST("FakeWOPIHost: Response to CheckFileInfo "
<< Poco::URI(request.getURI()).getPath() << ": 200 OK: " << json);
httpResponse->set("Last-Modified", Util::getHttpTime(getFileLastModifiedTime()));
httpResponse->setBody(json, "application/json; charset=utf-8");
}
else
{
LOG_TST("FakeWOPIHost: Response to CheckFileInfo "
<< Poco::URI(request.getURI()).getPath()
<< httpResponse->statusLine().statusCode() << ' '
<< httpResponse->statusLine().reasonPhrase());
}
socket->sendAndShutdown(*httpResponse);
return true;
}
/// Override to set the CheckFileInfo attributes.
virtual void configCheckFileInfo(const Poco::Net::HTTPRequest& /*request*/,
Poco::JSON::Object::Ptr& /*fileInfo*/)
{
}
virtual bool handleGetFileRequest(const Poco::Net::HTTPRequest& request,
std::shared_ptr<StreamSocket>& socket)
{
std::unique_ptr<http::Response> httpResponse = assertGetFileRequest(request);
if (!httpResponse)
httpResponse = std::make_unique<http::Response>(http::StatusCode::OK);
if (httpResponse->statusLine().statusCategory() ==
http::StatusLine::StatusCodeClass::Successful)
{
LOG_TST("FakeWOPIHost: Response to GetFile " << Poco::URI(request.getURI()).getPath()
<< ": 200 OK (" << getFileContent().size()
<< " bytes)");
httpResponse->set("Last-Modified", Util::getHttpTime(getFileLastModifiedTime()));
httpResponse->setBody(getFileContent(), "application/octet-stream");
}
else
{
LOG_TST("FakeWOPIHost: Response to GetFile "
<< Poco::URI(request.getURI()).getPath()
<< httpResponse->statusLine().statusCode() << ' '
<< httpResponse->statusLine().reasonPhrase());
}
socket->sendAndShutdown(*httpResponse);
return true;
}
virtual bool handleHttpGetRequest(const Poco::Net::HTTPRequest& request,
std::shared_ptr<StreamSocket>& socket)
{
LOG_ASSERT_MSG(request.getMethod() == "GET", "Expect an HTTP GET request");
const Poco::URI uriReq(request.getURI());
if (isWopiInfoRequest(uriReq.getPath())) // CheckFileInfo
{
++_countCheckFileInfo;
LOG_TST("FakeWOPIHost: Handling CheckFileInfo (#" << _countCheckFileInfo
<< "): " << uriReq.getPath());
return handleCheckFileInfoRequest(request, socket);
}
else if (isWopiContentRequest(uriReq.getPath())) // GetFile
{
++_countGetFile;
LOG_TST("FakeWOPIHost: Handling GetFile (#" << _countGetFile
<< "): " << uriReq.getPath());
return handleGetFileRequest(request, socket);
}
LOG_TST("FakeWOPIHost: not a wopi request, skipping: " << uriReq.getPath());
return false;
}
virtual bool handleHttpPostRequest(const Poco::Net::HTTPRequest& request,
Poco::MemoryInputStream& message,
std::shared_ptr<StreamSocket>& socket)
{
LOG_ASSERT_MSG(request.getMethod() == "POST", "Expect an HTTP POST request");
const Poco::URI uriReq(request.getURI());
if (isWopiInfoRequest(uriReq.getPath()))
{
if (!request.get("X-WOPI-Lock", std::string()).empty())
{
const std::string op = request.get("X-WOPI-Override", std::string());
if (op == "LOCK" || op == "UNLOCK")
{
std::unique_ptr<http::Response> response = assertLockRequest(request);
if (!response)
{
LOG_TST("FakeWOPIHost: " << op << " operation on [" << uriReq.getPath()
<< "] succeeded 200 OK");
http::Response httpResponse(http::StatusCode::OK);
socket->sendAndShutdown(httpResponse);
}
else
{
LOG_TST("FakeWOPIHost: " << op << " operation on [" << uriReq.getPath()
<< "]: " << response->statusLine().statusCode()
<< ' ' << response->statusLine().reasonPhrase());
socket->sendAndShutdown(*response);
}
}
else
{
http::Response httpResponse(http::StatusCode::Conflict);
httpResponse.set("X-WOPI-LockFailureReason", "Invalid lock operation");
socket->sendAndShutdown(httpResponse);
}
}
else
{
const std::string wopiURL =
helpers::getTestServerURI() +
"/something wopi/files/1?access_token=anything&reuse_cookies=cook=well";
std::string content;
if (request.get("X-WOPI-Override") == std::string("PUT_RELATIVE"))
{
++_countPutRelative;
LOG_TST("FakeWOPIHost: Handling PutRelativeFile (#"
<< _countPutRelative << "): " << uriReq.getPath());
LOK_ASSERT_EQUAL(std::string("PUT_RELATIVE"), request.get("X-WOPI-Override"));
assertPutRelativeFileRequest(request);
content = "{ \"Name\":\"hello world%1.pdf\", \"Url\":\"" + wopiURL + "\" }";
}
else
{
// rename file; response should be the file name without the url and the extension
LOK_ASSERT_EQUAL(std::string("RENAME_FILE"), request.get("X-WOPI-Override"));
assertRenameFileRequest(request);
content = "{ \"Name\":\"hello\", \"Url\":\"" + wopiURL + "\" }";
}
http::Response httpResponse(http::StatusCode::OK);
httpResponse.set("Last-Modified", Util::getHttpTime(getFileLastModifiedTime()));
httpResponse.setBody(content, "application/json; charset=utf-8");
socket->sendAndShutdown(httpResponse);
}
return true;
}
else if (isWopiContentRequest(uriReq.getPath()))
{
++_countPutFile;
LOG_TST("FakeWOPIHost: Handling PutFile (#" << _countPutFile
<< "): " << uriReq.getPath());
return handleWopiUpload(request, message, socket);
}
return false;
}
/// Handles the uploading of a document to wopi.
virtual bool handleWopiUpload(const Poco::Net::HTTPRequest& request,
Poco::MemoryInputStream& message,
std::shared_ptr<StreamSocket>& socket)
{
const std::string wopiTimestamp = request.get("X-COOL-WOPI-Timestamp", std::string());
if (!wopiTimestamp.empty())
{
const std::string fileModifiedTime =
Util::getIso8601FracformatTime(getFileLastModifiedTime());
if (wopiTimestamp != fileModifiedTime)
{
LOG_TST("FakeWOPIHost: Document conflict detected, Stored ModifiedTime: ["
<< fileModifiedTime << "], Upload ModifiedTime: [" << wopiTimestamp << ']');
http::Response httpResponse(http::StatusCode::Conflict);
httpResponse.setBody("{\"COOLStatusCode\":" +
std::to_string(static_cast<int>(COOLStatusCode::DocChanged)) +
'}');
socket->sendAndShutdown(httpResponse);
return true;
}
}
else
{
LOG_TST("FakeWOPIHost: Forced document upload");
}
std::unique_ptr<http::Response> response = assertPutFileRequest(request);
if (!response || response->statusLine().statusCategory() ==
http::StatusLine::StatusCodeClass::Successful)
{
const std::streamsize size = request.getContentLength();
LOG_TST("FakeWOPIHost: Writing document contents in storage (" << size << "bytes)");
std::vector<char> buffer(size);
message.read(buffer.data(), size);
setFileContent(Util::toString(buffer));
}
const Poco::URI uriReq(request.getURI());
if (response)
{
LOG_TST("FakeWOPIHost: Response to POST "
<< uriReq.getPath() << ": " << response->statusLine().statusCode() << ' '
<< response->statusLine().reasonPhrase());
socket->sendAndShutdown(*response);
}
else
{
// By default we return success.
const std::string body = "{\"LastModifiedTime\": \"" +
Util::getIso8601FracformatTime(getFileLastModifiedTime()) +
"\" }";
LOG_TST("FakeWOPIHost: Response (default) to POST " << uriReq.getPath()
<< ": 200 OK " << body);
http::Response httpResponse(http::StatusCode::OK);
httpResponse.setBody(body, "application/json; charset=utf-8");
socket->sendAndShutdown(httpResponse);
}
return true;
}
/// In some very rare cases we may get requests from other tests.
/// This asserts that the URI in question is for our test.
void assertTargetTest(const Poco::URI& uri)
{
const auto params = uri.getQueryParameters();
const auto testnameIt = std::find_if(params.begin(), params.end(),
[](const std::pair<std::string, std::string>& pair)
{ return pair.first == "testname"; });
LOK_ASSERT_MESSAGE_SILENT("Request belongs to an unknown test", testnameIt != params.end());
const std::string target = StringVector::tokenize(testnameIt->second, '/')[0];
LOK_ASSERT_EQUAL_MESSAGE("Request belongs to a different test", getTestname(), target);
}
/// Here we act as a WOPI server, so that we have a server that responds to
/// the wopi requests without additional expensive setup.
bool handleHttpRequest(const Poco::Net::HTTPRequest& request, Poco::MemoryInputStream& message,
std::shared_ptr<StreamSocket>& socket) override
{
Poco::URI uriReq(Util::decodeURIComponent(request.getURI()));
{
std::ostringstream oss;
oss << "FakeWOPIHost: " << request.getMethod() << " request URI [" << uriReq.toString()
<< "]:\n";
Util::joinPair(oss, request, " / ");
if (UnitBase::get().isFinished())
oss << "\nIgnoring as test has finished";
LOG_TST(oss.str());
if (UnitBase::get().isFinished())
return false;
}
assertTargetTest(uriReq);
wsd: test: track tests in URLs This helps detect when tests cross-connect. A rare, and very hard to find, issue went like this: A test (a random one) would recieve a request that it didn't expect. This would cause it to fail. It was clear that this was coming from a different test--but which? Meanwhile, another test (UnitWOPIHttpRedirect) was in connectLOKit retry loop due to repeated failure to connect. With this patch, it was easy to see what was happening, once the source was found. The idea is to stamp the test sending the request so the test-server can validate. Upon failure, we can see the source test. The source test (UnitWOPIHttpRedirect), in the case in question, is a server-side test. It flagged to exit once it verified that the redirection worked (in the GetFile serving the document). Unfortunately, exiting didn't stop the connection attempts in the same test. In most cases the document would load before DocBroker is stopped. But every so often everything would shutdown and the test would keep trying to connectLOKit. Since we shutdown everything, we also closed the listening port, which is now available for other tests to re-use. This is how UnitWOPIHttpRedirect requests ended up at different tests, causing them to fail sporadically. The subsequent test fixes the retry logic. The fix is left separate since this patch is useful on its own, and independent of the issue and the fix. This is just a tool. Change-Id: I2848cc578a102fc0bd981e1ac71aaabc25f1a403 Signed-off-by: Ashod Nakashian <ashod.nakashian@collabora.co.uk>
2022-07-29 20:07:30 -05:00
if (request.getMethod() == "GET")
{
return handleHttpGetRequest(request, socket);
}
else if (request.getMethod() == "POST")
{
return handleHttpPostRequest(request, message, socket);
}
else if (!uriReq.getPath().starts_with("/cool/")) // Skip requests to the websrv.
{
// Complain if we are expected to handle something that we don't.
LOG_TST("ERROR: FakeWOPIHost: Request, cannot handle request: " << uriReq.getPath());
}
return false;
}
};
/* vim:set shiftwidth=4 softtabstop=4 expandtab: */