tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / macos (push) Successful in 1m22s
tests / linux (pull_request) Successful in 1m54s
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 2m43s
tests / windows (push) Successful in 4m13s
tests / windows (pull_request) Skipped
tests / macos (pull_request) Skipped
tests / package-smoke-linux (pull_request) Skipped
tests / package-smoke-windows (pull_request) Skipped
tests / package-smoke-macos (pull_request) Skipped
Signed-off-by: galeon <dertiellis@gmail.com>
450 lines
15 KiB
C++
450 lines
15 KiB
C++
#include "fakeinternet.h"
|
||
|
||
#include "fixtures.h"
|
||
|
||
#include <QEventLoop>
|
||
#include <QHostAddress>
|
||
#include <QNetworkAccessManager>
|
||
#include <QNetworkReply>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QNetworkProxy>
|
||
#include <QSslConfiguration>
|
||
#include <QSslKey>
|
||
#include <QSslSocket>
|
||
#include <QTimer>
|
||
|
||
struct FakeInternet::Connection
|
||
{
|
||
QSslSocket *socket = nullptr;
|
||
QByteArray buffer;
|
||
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
|
||
};
|
||
|
||
namespace {
|
||
|
||
QByteArray reasonPhrase(int status)
|
||
{
|
||
switch (status) {
|
||
case 200: return "OK";
|
||
case 201: return "Created";
|
||
case 204: return "No Content";
|
||
case 301: return "Moved Permanently";
|
||
case 302: return "Found";
|
||
case 304: return "Not Modified";
|
||
case 400: return "Bad Request";
|
||
case 401: return "Unauthorized";
|
||
case 403: return "Forbidden";
|
||
case 404: return "Not Found";
|
||
case 410: return "Gone";
|
||
case 429: return "Too Many Requests";
|
||
case 500: return "Internal Server Error";
|
||
case 502: return "Bad Gateway";
|
||
case 503: return "Service Unavailable";
|
||
default: return "Status";
|
||
}
|
||
}
|
||
|
||
} // namespace
|
||
|
||
QUrl FakeRequest::url() const
|
||
{
|
||
QUrl result;
|
||
result.setScheme(scheme);
|
||
result.setHost(host);
|
||
result.setPath(path);
|
||
if (!query.isEmpty())
|
||
result.setQuery(query);
|
||
return result;
|
||
}
|
||
|
||
FakeResponse FakeResponse::json(const QByteArray &body, int status)
|
||
{
|
||
FakeResponse response;
|
||
response.status = status;
|
||
response.body = body;
|
||
response.contentType = "application/json";
|
||
return response;
|
||
}
|
||
|
||
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
|
||
{
|
||
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
|
||
}
|
||
|
||
FakeResponse FakeResponse::text(const QByteArray &body, int status)
|
||
{
|
||
FakeResponse response;
|
||
response.status = status;
|
||
response.body = body;
|
||
response.contentType = "text/plain; charset=utf-8";
|
||
return response;
|
||
}
|
||
|
||
FakeResponse FakeResponse::bytes(const QByteArray &body)
|
||
{
|
||
FakeResponse response;
|
||
response.body = body;
|
||
return response;
|
||
}
|
||
|
||
FakeResponse FakeResponse::error(int status, const QByteArray &body)
|
||
{
|
||
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
|
||
return response;
|
||
}
|
||
|
||
FakeInternet::FakeInternet(QObject *parent)
|
||
: QTcpServer(parent)
|
||
{
|
||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||
}
|
||
|
||
FakeInternet::~FakeInternet()
|
||
{
|
||
stop();
|
||
}
|
||
|
||
bool FakeInternet::tlsAvailable(QString *why)
|
||
{
|
||
if (!QSslSocket::supportsSsl()) {
|
||
if (why)
|
||
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool FakeInternet::start(QString *error)
|
||
{
|
||
if (m_started)
|
||
return true;
|
||
if (!tlsAvailable(error))
|
||
return false;
|
||
|
||
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
|
||
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
|
||
|
||
if (!listen(QHostAddress::LocalHost, 0)) {
|
||
if (error)
|
||
*error = errorString();
|
||
return false;
|
||
}
|
||
|
||
QNetworkProxy::setApplicationProxy(
|
||
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
|
||
|
||
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
|
||
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
|
||
QSslConfiguration::setDefaultConfiguration(ssl);
|
||
|
||
m_started = true;
|
||
|
||
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
|
||
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
|
||
// чем получить десятки непонятных падений в тестах сервисов.
|
||
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
|
||
route(probe, FakeResponse::text("pong"));
|
||
QNetworkAccessManager manager;
|
||
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
|
||
QEventLoop loop;
|
||
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
|
||
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||
if (!reply->isFinished())
|
||
loop.exec();
|
||
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
|
||
&& reply->readAll() == "pong";
|
||
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
|
||
delete reply;
|
||
reset();
|
||
if (!ok) {
|
||
stop();
|
||
if (error)
|
||
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
|
||
.arg(why, QSslSocket::activeBackend());
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void FakeInternet::stop()
|
||
{
|
||
if (!m_started)
|
||
return;
|
||
// Никуда наружу и после остановки: прокси на закрытый порт.
|
||
close();
|
||
m_started = false;
|
||
}
|
||
|
||
void FakeInternet::route(const QString &pattern, const Handler &handler)
|
||
{
|
||
unroute(pattern);
|
||
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
|
||
Route route;
|
||
route.pattern = pattern;
|
||
route.host = url.host().toLower();
|
||
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
|
||
route.prefix = pattern.endsWith(u'*');
|
||
route.handler = handler;
|
||
m_routes << route;
|
||
}
|
||
|
||
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
|
||
{
|
||
route(pattern, [response](const FakeRequest &) { return response; });
|
||
}
|
||
|
||
void FakeInternet::unroute(const QString &pattern)
|
||
{
|
||
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
|
||
}
|
||
|
||
void FakeInternet::reset()
|
||
{
|
||
m_deferred.clear();
|
||
m_diagnostics.clear();
|
||
m_routes.clear();
|
||
m_requests.clear();
|
||
m_unmatched.clear();
|
||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||
}
|
||
|
||
bool FakeInternet::releaseResponse(const QString &url)
|
||
{
|
||
for (qsizetype i = 0; i < m_deferred.size(); ++i) {
|
||
if (m_deferred.at(i).url == url) {
|
||
const auto pending = m_deferred.takeAt(i);
|
||
respond(pending.socket, pending.response, pending.keepAlive);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
QString FakeInternet::diagnostics() const
|
||
{
|
||
return QStringLiteral("TLS backend: %1\nRequests: %2\n%3")
|
||
.arg(QSslSocket::activeBackend(), requestedUrls().join(u' '), m_diagnostics.join(u'\n'));
|
||
}
|
||
|
||
QList<FakeRequest> FakeInternet::requests() const
|
||
{
|
||
return m_requests;
|
||
}
|
||
|
||
int FakeInternet::hits(const QString &url) const
|
||
{
|
||
const QUrl wanted(url);
|
||
int count = 0;
|
||
for (const FakeRequest &request : m_requests) {
|
||
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
|
||
&& request.path == wanted.path())
|
||
++count;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
QStringList FakeInternet::unmatched() const
|
||
{
|
||
return m_unmatched;
|
||
}
|
||
|
||
QStringList FakeInternet::requestedUrls() const
|
||
{
|
||
QStringList result;
|
||
for (const FakeRequest &request : m_requests)
|
||
result << request.url().toString();
|
||
return result;
|
||
}
|
||
|
||
void FakeInternet::incomingConnection(qintptr descriptor)
|
||
{
|
||
auto *socket = new QSslSocket(this);
|
||
if (!socket->setSocketDescriptor(descriptor)) {
|
||
delete socket;
|
||
return;
|
||
}
|
||
|
||
auto *connection = new Connection;
|
||
connection->socket = socket;
|
||
|
||
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
|
||
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
|
||
socket->ignoreSslErrors();
|
||
});
|
||
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
|
||
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
|
||
socket->deleteLater();
|
||
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
|
||
});
|
||
}
|
||
|
||
void FakeInternet::onReadyRead(Connection *connection)
|
||
{
|
||
connection->buffer += connection->socket->readAll();
|
||
processBuffer(connection);
|
||
}
|
||
|
||
void FakeInternet::processBuffer(Connection *connection)
|
||
{
|
||
for (;;) {
|
||
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
|
||
if (headerEnd < 0)
|
||
return;
|
||
|
||
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
|
||
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
|
||
if (requestLine.size() < 2) {
|
||
connection->socket->abort();
|
||
return;
|
||
}
|
||
|
||
FakeRequest request;
|
||
request.method = requestLine.at(0);
|
||
for (qsizetype i = 1; i < lines.size(); ++i) {
|
||
const QByteArray line = lines.at(i).trimmed();
|
||
const qsizetype colon = line.indexOf(':');
|
||
if (colon > 0)
|
||
request.headers.insert(line.left(colon).trimmed().toLower(),
|
||
line.mid(colon + 1).trimmed());
|
||
}
|
||
|
||
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
|
||
if (connection->buffer.size() < headerEnd + 4 + bodySize)
|
||
return; // тело ещё не дошло
|
||
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
|
||
connection->buffer.remove(0, headerEnd + 4 + bodySize);
|
||
|
||
const QByteArray target = requestLine.at(1);
|
||
|
||
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
|
||
QString host = QString::fromLatin1(target);
|
||
host = host.left(host.lastIndexOf(u':')).toLower();
|
||
connection->tunnelHost = host;
|
||
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||
connection->socket->flush();
|
||
startTls(connection);
|
||
continue;
|
||
}
|
||
|
||
if (!connection->tunnelHost.isEmpty()) {
|
||
request.scheme = QStringLiteral("https");
|
||
request.host = connection->tunnelHost;
|
||
const QUrl relative(QString::fromLatin1(target));
|
||
request.path = relative.path(QUrl::FullyDecoded);
|
||
request.query = relative.query(QUrl::FullyEncoded);
|
||
} else {
|
||
// Простой http через прокси — адрес в absolute-form.
|
||
const QUrl absolute(QString::fromLatin1(target));
|
||
request.scheme = absolute.scheme();
|
||
request.host = absolute.host().toLower();
|
||
request.path = absolute.path(QUrl::FullyDecoded);
|
||
request.query = absolute.query(QUrl::FullyEncoded);
|
||
}
|
||
if (request.path.isEmpty())
|
||
request.path = QStringLiteral("/");
|
||
|
||
dispatch(connection, request);
|
||
}
|
||
}
|
||
|
||
void FakeInternet::startTls(Connection *connection)
|
||
{
|
||
QSslSocket *socket = connection->socket;
|
||
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
|
||
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
|
||
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
|
||
socket->startServerEncryption();
|
||
}
|
||
|
||
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
|
||
{
|
||
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
|
||
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
|
||
const Route &route = m_routes.at(i);
|
||
if (route.host != request.host)
|
||
continue;
|
||
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
|
||
return &route;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||
{
|
||
m_requests << request;
|
||
const QString url = request.url().toString();
|
||
|
||
FakeResponse response;
|
||
if (const Route *route = match(request)) {
|
||
response = route->handler(request);
|
||
} else {
|
||
m_unmatched << url;
|
||
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
|
||
qPrintable(url));
|
||
response = m_fallback;
|
||
}
|
||
|
||
const bool keepAlive = request.header("connection").toLower() != "close";
|
||
QPointer<QSslSocket> socket(connection->socket);
|
||
if (response.hang)
|
||
return;
|
||
if (response.deferred) {
|
||
m_deferred.append({url, socket, response, keepAlive});
|
||
return;
|
||
}
|
||
if (response.delayMs > 0) {
|
||
QTimer::singleShot(response.delayMs, this,
|
||
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||
} else {
|
||
respond(socket, response, keepAlive);
|
||
}
|
||
emit requestServed(url);
|
||
}
|
||
|
||
void FakeInternet::respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive)
|
||
{
|
||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||
return;
|
||
if (response.drop) {
|
||
socket->abort();
|
||
return;
|
||
}
|
||
|
||
QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' '
|
||
+ reasonPhrase(response.status) + "\r\n";
|
||
head += "Content-Type: " + response.contentType + "\r\n";
|
||
head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n";
|
||
for (const auto &[name, value] : response.headers)
|
||
head += name + ": " + value + "\r\n";
|
||
const bool close = !keepAlive || response.truncateTo >= 0;
|
||
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||
head += "\r\n";
|
||
|
||
if (response.truncateTo >= 0) {
|
||
m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2")
|
||
.arg(response.body.size()).arg(response.body.left(response.truncateTo).size());
|
||
// Сначала отправляем TLS-записи с неполным телом, затем рвём TCP.
|
||
// disconnectFromHost() выполняет штатное завершение TLS, а здесь
|
||
// моделируется обрыв. Готовность определяется буферами, не задержкой.
|
||
connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) {
|
||
QTimer::singleShot(0, socket, [this, socket] {
|
||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||
return;
|
||
if (socket->bytesToWrite() || socket->encryptedBytesToWrite())
|
||
return;
|
||
m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP");
|
||
socket->abort();
|
||
m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state());
|
||
});
|
||
});
|
||
socket->write(head);
|
||
socket->write(response.body.left(response.truncateTo));
|
||
return;
|
||
}
|
||
socket->write(head);
|
||
socket->write(response.body);
|
||
if (close)
|
||
socket->disconnectFromHost();
|
||
}
|