Files
docview/tests/test_windows_worker.cpp
T
2026-09-21 13:41:40 +09:00

199 lines
12 KiB
C++

// Compile/run only on native Windows with the production LPAC bootstrap enabled.
#include <QtGlobal>
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include "common/worker_process.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTest>
#include <vector>
using namespace docview;
namespace {
struct Fixture {
QTemporaryDir directory{QDir::tempPath() + "/docview-win-probe-XXXXXX"};
QString source;
QByteArray sourceBytes;
std::vector<HANDLE> sentinels;
~Fixture() { for (auto handle : sentinels) CloseHandle(handle); }
bool prepare(const QString &mode, quint16 port = 0) {
if (!directory.isValid()) return false;
source = directory.filePath("source.json");
if (!QDir().mkpath(directory.filePath("config")) || !QDir().mkpath(directory.filePath("cache"))) return false;
const auto put = [](const QString &path, const QByteArray &bytes) {
QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size();
};
const auto sibling = directory.filePath("neighbor.txt"), config = directory.filePath("config/user-settings.json");
if (!put(sibling, "TEMPORARY NEIGHBOR FIXTURE") || !put(config, "{\"privateFixture\":true}")) return false;
QJsonArray handles;
for (int i = 0; i < 2; ++i) {
const auto path = directory.filePath(QString("sentinel-%1.txt").arg(i));
if (!put(path, "TEMPORARY INHERITANCE SENTINEL")) return false;
SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE};
const auto name = QDir::toNativeSeparators(path).toStdWString();
const auto handle = CreateFileW(name.c_str(), GENERIC_READ, FILE_SHARE_READ, &attributes, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE) return false;
sentinels.push_back(handle);
BY_HANDLE_FILE_INFORMATION info{}; DWORD flags = 0;
if (!GetFileInformationByHandle(handle, &info) || !GetHandleInformation(handle, &flags) || !(flags & HANDLE_FLAG_INHERIT)) return false;
handles.append(QJsonObject{{"value", QString::number(reinterpret_cast<quintptr>(handle))},
{"volume", qint64(info.dwVolumeSerialNumber)}, {"high", qint64(info.nFileIndexHigh)}, {"low", qint64(info.nFileIndexLow)}});
}
const QJsonObject input{{"mode", mode}, {"fixtureRoot", directory.path()}, {"sourcePath", source},
{"siblingPath", sibling}, {"configPath", config}, {"outputPath", directory.filePath("cache/worker-output.bin")},
{"loopbackPort", int(port)}, {"sentinelHandles", handles}};
sourceBytes = QJsonDocument(input).toJson(QJsonDocument::Compact);
return put(source, sourceBytes);
}
};
QString probeExecutable() {
return QDir(QCoreApplication::applicationDirPath()).filePath("docview-windows-worker-probe.exe");
}
bool launch(WorkerProcess &worker, const Fixture &fixture) {
return worker.start(probeExecutable(), {"--source", fixture.source});
}
}
class WindowsWorkerTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() {
QVERIFY2(QFileInfo::exists(probeExecutable()), "Build/deploy docview-windows-worker-probe with its generated LPAC-compatible manifest and Qt dependencies");
// A missing/unavailable sandbox is a failed native release gate. No
// skip or unrestricted-worker fallback is accepted in this suite.
}
void protectedReadinessAndReplyOrder() {
Fixture fixture; QVERIFY(fixture.prepare("echo"));
WorkerProcess worker("native-session", 37);
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
QList<QCborMap> replies;
QVERIFY(launch(worker, fixture));
worker.request("render", {{"ordinal", 1}}, [&](const QCborMap &r) { replies << r; });
worker.request("metadata", {{"ordinal", 2}}, [&](const QCborMap &r) { replies << r; });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 20000);
QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0);
for (int i = 0; i < 2; ++i) {
QCOMPARE(replies[i].value("requestId").toInteger(), i + 2); // first ping is bootstrap
QCOMPARE(replies[i].value("sessionId").toString(), "native-session");
QCOMPARE(replies[i].value("generation").toInteger(), 37);
const auto result = replies[i].value("result").toMap();
QCOMPARE(result.value("received").toInteger(), i + 2);
QCOMPARE(result.value("echo").toMap().value("ordinal").toInteger(), i + 1);
QVERIFY(result.value("sandboxed").toBool());
}
QVERIFY(worker.idle()); worker.stop(); QCOMPARE(failures.size(), 0);
}
void rejectsHostileRepliesAfterReady_data() {
QTest::addColumn<QString>("mode");
for (const auto *mode : {"wrong-session", "wrong-generation", "wrong-request", "wrong-operation", "wrong-version", "oversize", "illegal-more"})
QTest::newRow(mode) << QString::fromLatin1(mode);
}
void rejectsHostileRepliesAfterReady() {
QFETCH(QString, mode); Fixture fixture; QVERIFY(fixture.prepare(mode));
WorkerProcess worker("protected-session", 7);
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
int callbacks = 0;
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
worker.request("render", {}, [&](const QCborMap &) { ++callbacks; });
worker.request("metadata", {}, [&](const QCborMap &) { ++callbacks; });
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH");
QCOMPARE(callbacks, 0); QVERIFY(worker.idle());
QTest::qWait(100); QCOMPARE(failures.size(), 1);
}
void classifiesProtectedExit_data() {
QTest::addColumn<QString>("mode"); QTest::addColumn<QString>("code");
QTest::newRow("ordinary") << QString("crash") << QString("E_WORKER_CRASH");
QTest::newRow("sandbox") << QString("sandbox-unavailable") << QString("E_SANDBOX_UNAVAILABLE");
}
void classifiesProtectedExit() {
QFETCH(QString, mode); QFETCH(QString, code); Fixture fixture; QVERIFY(fixture.prepare(mode));
WorkerProcess worker("exit-session", 1); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
int callbacks = 0;
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
worker.request("render", {}, [&](const QCborMap &) { ++callbacks; });
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
QCOMPARE(failures.first()[0].toString(), code); QCOMPARE(callbacks, 0);
QTest::qWait(100); QCOMPARE(failures.size(), 1);
}
void normalErrorAndStreamKeepProtocolUsable() {
Fixture fixture; QVERIFY(fixture.prepare("stream"));
WorkerProcess worker("stream-session", 3); QSignalSpy failures(&worker, &WorkerProcess::failed);
QByteArray bytes; QStringList order; bool final = false;
QVERIFY(launch(worker, fixture));
worker.request("extract", {{"path", "fixture"}}, [&](const QCborMap &r) {
const auto result = r.value("result").toMap();
if (result.value("more").toBool()) { QVERIFY(!worker.idle()); QVERIFY(!final); bytes += result.value("data").toByteArray(); order << "chunk"; }
else { final = true; QCOMPARE(result.value("size").toInteger(), 9); order << "final"; }
});
worker.request("metadata", {}, [&](const QCborMap &r) {
QVERIFY(final); QVERIFY(!r.value("result").toMap().value("receivedDuringStream").toBool()); order << "next";
});
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 4, 20000);
QCOMPARE(bytes, QByteArray("alphabeta")); QCOMPARE(order, (QStringList{"chunk", "chunk", "final", "next"})); QCOMPARE(failures.size(), 0);
worker.stop();
Fixture errors; QVERIFY(errors.prepare("error")); WorkerProcess errorWorker("errors", 1);
QSignalSpy errorFailures(&errorWorker, &WorkerProcess::failed); QList<QCborMap> replies;
QVERIFY(launch(errorWorker, errors)); errorWorker.request("open", {}, [&](const QCborMap &r) { replies << r; });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 20000);
QCOMPARE(replies.first().value("error").toMap().value("code").toString(), "E_FIXTURE"); QCOMPARE(errorFailures.size(), 0);
}
void actualLpacOsBoundaries() {
QTcpServer listener; QVERIFY(listener.listen(QHostAddress::LocalHost, 0));
QSignalSpy connections(&listener, &QTcpServer::newConnection);
Fixture fixture; QVERIFY(fixture.prepare("probe-os", listener.serverPort()));
WorkerProcess worker("boundary-session", 9); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
QList<QCborMap> replies;
QVERIFY(launch(worker, fixture)); worker.request("probe", {}, [&](const QCborMap &r) { replies << r; });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 25000);
QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0);
const auto result = replies.first().value("result").toMap();
for (const auto *key : {"fixture_valid", "source_handle_write_denied", "source_reopen_write_denied", "parent_read_denied",
"sibling_read_denied", "config_read_denied", "arbitrary_output_denied", "extra_file_handles_absent", "loopback_denied",
"child_creation_denied", "own_profile_write_denied", "own_profile_registry_write_denied"}) {
QVERIFY2(result.value(QString::fromLatin1(key)).toBool(), qPrintable(QString::fromLatin1(key) + ": " + QString::fromUtf8(QJsonDocument::fromVariant(result.toVariantMap()).toJson(QJsonDocument::Compact))));
}
QCOMPARE(connections.size(), 0); worker.stop();
QFile source(fixture.source); QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), fixture.sourceBytes);
QVERIFY(!QFileInfo::exists(fixture.directory.filePath("cache/worker-output.bin")));
}
void gracefulCloseRequiresReply_data() {
QTest::addColumn<bool>("acknowledged");
QTest::newRow("reply-before-exit") << true;
QTest::newRow("exit-without-reply") << false;
}
void gracefulCloseRequiresReply() {
QFETCH(bool, acknowledged);
Fixture fixture; QVERIFY(fixture.prepare(acknowledged ? "close-reply-exit" : "close-without-reply"));
WorkerProcess worker("close-session", 1);
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
int closed = 0, afterClose = 0;
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
worker.request("close", {}, [&](const QCborMap &reply) {
QVERIFY(reply.value("result").toMap().value("closed").toBool()); ++closed;
});
worker.request("metadata", {}, [&](const QCborMap &) { ++afterClose; });
if (acknowledged) {
QTRY_COMPARE_WITH_TIMEOUT(closed, 1, 10000);
QTest::qWait(100); QCOMPARE(failures.size(), 0);
} else {
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH"); QCOMPARE(closed, 0);
}
QCOMPARE(afterClose, 0); QVERIFY(worker.idle()); QVERIFY(!worker.isConnected());
}
};
QTEST_GUILESS_MAIN(WindowsWorkerTests)
#include "test_windows_worker.moc"