1 parent
ebf8ff7869
commit
41599fa82f
67 files changed
+8775
-4
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# Сетевые сервисы против FakeInternet (локальный прокси + TLS, без настоящей сети).
|
||||
foreach(test IN ITEMS
|
||||
tst_fakeinternet
|
||||
tst_versionmanifestservice
|
||||
tst_seasonalbuildservice
|
||||
tst_modloaderversionservice
|
||||
tst_javaruntimeservice
|
||||
tst_authservice
|
||||
tst_msaauthservice
|
||||
tst_versioninstaller
|
||||
tst_seasonalpackdownloader
|
||||
tst_javainstaller
|
||||
tst_modloaderinstaller)
|
||||
bilauncher_add_test(${test} LABEL net SOURCES ${test}.cpp)
|
||||
endforeach()
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
|
||||
const QString kValidate = QStringLiteral("https://authserver.ely.by/auth/validate");
|
||||
const QString kRefresh = QStringLiteral("https://authserver.ely.by/auth/refresh");
|
||||
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||
const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector-1.2.5.jar");
|
||||
|
||||
QJsonObject body(const FakeRequest &request)
|
||||
{
|
||||
return QJsonDocument::fromJson(request.body).object();
|
||||
}
|
||||
|
||||
QJsonObject profileResponse(const QString &token, const QString &clientToken)
|
||||
{
|
||||
return QJsonObject{{"accessToken", token},
|
||||
{"clientToken", clientToken},
|
||||
{"selectedProfile", QJsonObject{{"id", "ffffffff-ffff-ffff-ffff-ffffffffffff"},
|
||||
{"name", "ElyPlayer"}}}};
|
||||
}
|
||||
|
||||
// Сервер Ely.by в миниатюре: пароль «secret», у пользователя «2fa» — ещё код 123456.
|
||||
FakeResponse elyAuthenticate(const FakeRequest &request)
|
||||
{
|
||||
const QJsonObject in = body(request);
|
||||
const QString user = in.value("username").toString();
|
||||
const QString password = in.value("password").toString();
|
||||
if (user == QLatin1String("2fa")) {
|
||||
if (password == QLatin1String("secret:123456"))
|
||||
return FakeResponse::json(profileResponse("token-2fa", in.value("clientToken").toString()));
|
||||
if (password == QLatin1String("secret"))
|
||||
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
|
||||
{"errorMessage", "Account protected with two factor auth."}}, 401);
|
||||
}
|
||||
if (password == QLatin1String("secret"))
|
||||
return FakeResponse::json(profileResponse("token-" + user, in.value("clientToken").toString()));
|
||||
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
|
||||
{"errorMessage", "Invalid credentials. Invalid username or password."}}, 401);
|
||||
}
|
||||
|
||||
struct Captured
|
||||
{
|
||||
int calls = 0;
|
||||
AuthResult result;
|
||||
auto callback()
|
||||
{
|
||||
return [this](const AuthResult &r) { ++calls; result = r; };
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestAuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_dir;
|
||||
|
||||
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
m_net.route(kAuth, elyAuthenticate);
|
||||
m_dir = TestEnv::freshDir(QStringLiteral("injector"));
|
||||
}
|
||||
|
||||
void passwordLogin()
|
||||
{
|
||||
AuthService service;
|
||||
QSignalSpy progress(&service, &AuthService::progress);
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "client-1", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
|
||||
QCOMPARE(c.result.uuid, QStringLiteral("ffffffffffffffffffffffffffffffff")); // без дефисов
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
|
||||
QCOMPARE(c.result.clientToken, QStringLiteral("client-1"));
|
||||
QCOMPARE(c.result.userType, QStringLiteral("ELYBY"));
|
||||
QCOMPARE(progress.count(), 1);
|
||||
|
||||
const QJsonObject sent = body(m_net.requests().constFirst());
|
||||
QCOMPARE(sent.value("username").toString(), QStringLiteral("steve"));
|
||||
QCOMPARE(sent.value("clientToken").toString(), QStringLiteral("client-1"));
|
||||
QCOMPARE(sent.value("requestUser").toBool(), true);
|
||||
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArray("application/json"));
|
||||
}
|
||||
|
||||
void emptyClientTokenIsGenerated()
|
||||
{
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", QString(), QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QVERIFY(!c.result.clientToken.isEmpty());
|
||||
QCOMPARE(body(m_net.requests().constFirst()).value("clientToken").toString(), c.result.clientToken);
|
||||
}
|
||||
|
||||
void wrongPasswordAndEmptyPassword()
|
||||
{
|
||||
AuthService service;
|
||||
Captured wrong;
|
||||
service.loginElyBy("steve", "nope", "c", QString(), wrong.callback());
|
||||
QVERIFY(wait(wrong));
|
||||
QVERIFY(!wrong.result.ok);
|
||||
QVERIFY(!wrong.result.twoFactorRequired);
|
||||
QCOMPARE(wrong.result.error, QStringLiteral("Invalid credentials. Invalid username or password."));
|
||||
|
||||
Captured empty;
|
||||
service.loginElyBy("steve", QString(), "c", QString(), empty.callback());
|
||||
QCOMPARE(empty.calls, 1); // сразу, без сети
|
||||
QCOMPARE(empty.result.error, Loc::text("auth.ely.error.noPassword"));
|
||||
QCOMPARE(m_net.requests().size(), 1);
|
||||
}
|
||||
|
||||
void twoFactorFlow()
|
||||
{
|
||||
AuthService service;
|
||||
Captured first;
|
||||
service.loginElyBy("2fa", "secret", "c", QString(), first.callback());
|
||||
QVERIFY(wait(first));
|
||||
QVERIFY(!first.result.ok);
|
||||
QVERIFY(first.result.twoFactorRequired);
|
||||
QCOMPARE(first.result.error, Loc::text("auth.ely.error.twoFactor"));
|
||||
|
||||
Captured second;
|
||||
service.loginElyByWithTotp("2fa", "secret", "123456", "c", second.callback());
|
||||
QVERIFY(wait(second));
|
||||
QVERIFY2(second.result.ok, qPrintable(second.result.error));
|
||||
QCOMPARE(second.result.accessToken, QStringLiteral("token-2fa"));
|
||||
// Код уходит через двоеточие после пароля — так требует Ely.by.
|
||||
QCOMPARE(body(m_net.requests().constLast()).value("password").toString(), QStringLiteral("secret:123456"));
|
||||
}
|
||||
|
||||
void validTokenSkipsPassword()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json("{}"));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "stored-token", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("stored-token"));
|
||||
QCOMPARE(m_net.hits(kAuth), 0);
|
||||
QCOMPARE(body(m_net.requests().constFirst()).value("accessToken").toString(), QStringLiteral("stored-token"));
|
||||
}
|
||||
|
||||
void expiredTokenIsRefreshed()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"}}, 401));
|
||||
m_net.route(kRefresh, [](const FakeRequest &request) {
|
||||
const QJsonObject in = body(request);
|
||||
return FakeResponse::json(profileResponse("refreshed-" + in.value("accessToken").toString(),
|
||||
in.value("clientToken").toString()));
|
||||
});
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "old", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("refreshed-old"));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
|
||||
QCOMPARE(m_net.hits(kAuth), 0);
|
||||
}
|
||||
|
||||
void failedRefreshFallsBackToPassword()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json("{}", 401));
|
||||
m_net.route(kRefresh, FakeResponse::json("{}", 401));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "old", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
|
||||
QCOMPARE(m_net.hits(kAuth), 1);
|
||||
}
|
||||
|
||||
void transportErrorIsReported()
|
||||
{
|
||||
FakeResponse drop;
|
||||
drop.drop = true;
|
||||
m_net.route(kAuth, drop);
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QVERIFY(c.result.error.startsWith(Loc::text("auth.ely.error.unreachable").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void responseWithoutProfileIsError()
|
||||
{
|
||||
m_net.route(kAuth, FakeResponse::json(QJsonObject{{"accessToken", "t"}}));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, Loc::text("auth.ely.error.noGameProfile"));
|
||||
}
|
||||
|
||||
void serverErrorWithoutMessageShowsCode()
|
||||
{
|
||||
m_net.route(kAuth, FakeResponse::json("{}", 500));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QCOMPARE(c.result.error, Loc::text("auth.error.serverCode").arg(500));
|
||||
}
|
||||
|
||||
// --- authlib-injector -------------------------------------------------------
|
||||
|
||||
void injectorDownloadVerifyAndCache()
|
||||
{
|
||||
const QByteArray jar = "PK\x03\x04 fake authlib-injector";
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
|
||||
{"build_number", 53}, {"version", "1.2.5"},
|
||||
{"download_url", kInjectorJar},
|
||||
{"checksums", QJsonObject{{"sha256", Fixtures::sha256(jar)}}}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::bytes(jar));
|
||||
|
||||
AuthService service;
|
||||
QString path, error;
|
||||
int calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QVERIFY2(error.isEmpty(), qPrintable(error));
|
||||
QCOMPARE(path, m_dir + "/authlib-injector.jar");
|
||||
QCOMPARE(Fixtures::readFile(path), jar);
|
||||
|
||||
// Второй раз — сразу из файла.
|
||||
calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QCOMPARE(calls, 1);
|
||||
QCOMPARE(m_net.hits(kInjectorJar), 1);
|
||||
}
|
||||
|
||||
void injectorChecksumMismatch()
|
||||
{
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
|
||||
{"download_url", kInjectorJar},
|
||||
{"checksums", QJsonObject{{"sha256", QString(64, u'0')}}}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::bytes("tampered"));
|
||||
AuthService service;
|
||||
QString path, error;
|
||||
int calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QCOMPARE(error, Loc::text("auth.ely.error.injectorChecksum"));
|
||||
QVERIFY(path.isEmpty());
|
||||
QVERIFY(!QFile::exists(m_dir + "/authlib-injector.jar"));
|
||||
}
|
||||
|
||||
void injectorManifestProblems()
|
||||
{
|
||||
AuthService service;
|
||||
QString error;
|
||||
int calls = 0;
|
||||
const auto cb = [&](const QString &, const QString &e) { ++calls; error = e; };
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"version", "1.2.5"}}));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QCOMPARE(error, Loc::text("auth.ely.error.injectorNoUrl"));
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::error(503));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 2, 15000);
|
||||
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorInfoFailed").section(u'%', 0, 0)));
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::error(404));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 3, 15000);
|
||||
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorDownloadFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestAuthService)
|
||||
#include "tst_authservice.moc"
|
||||
@@ -0,0 +1,135 @@
|
||||
// Самопроверка FakeInternet: если она красная, остальные сетевые тесты
|
||||
// бессмысленны — значит, перехват трафика на этой платформе не работает.
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakeinternet.h"
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestFakeInternet : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
QNetworkReply *get(QNetworkAccessManager &manager, const QString &url)
|
||||
{
|
||||
return manager.get(QNetworkRequest(QUrl(url)));
|
||||
}
|
||||
|
||||
static bool waitFinished(QNetworkReply *reply)
|
||||
{
|
||||
if (reply->isFinished())
|
||||
return true;
|
||||
QSignalSpy spy(reply, &QNetworkReply::finished);
|
||||
return spy.wait(15000);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QString error;
|
||||
if (!m_net.start(&error))
|
||||
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
|
||||
}
|
||||
|
||||
void init() { m_net.reset(); }
|
||||
|
||||
void httpsGetIsIntercepted()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"),
|
||||
FakeResponse::json(QByteArrayLiteral("{\"ok\":true}")));
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> reply(
|
||||
get(manager, QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json?x=1")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->error(), QNetworkReply::NoError);
|
||||
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"ok\":true}"));
|
||||
QCOMPARE(m_net.hits(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")), 1);
|
||||
QCOMPARE(m_net.requests().constFirst().query, QStringLiteral("x=1"));
|
||||
}
|
||||
|
||||
void postBodyAndHeaders()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://authserver.ely.by/auth/authenticate"),
|
||||
[](const FakeRequest &request) {
|
||||
return FakeResponse::json(request.body, request.method == "POST" ? 200 : 405);
|
||||
});
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request(QUrl(QStringLiteral("https://authserver.ely.by/auth/authenticate")));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
QScopedPointer<QNetworkReply> reply(manager.post(request, QByteArrayLiteral("{\"a\":1}")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"a\":1}"));
|
||||
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArrayLiteral("application/json"));
|
||||
}
|
||||
|
||||
void prefixRoutesAndKeepAlive()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://resources.download.minecraft.net/*"),
|
||||
[](const FakeRequest &request) { return FakeResponse::text(request.path.toUtf8()); });
|
||||
QNetworkAccessManager manager;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
const QString path = QStringLiteral("/ab/ab%1").arg(i);
|
||||
QScopedPointer<QNetworkReply> reply(
|
||||
get(manager, QStringLiteral("https://resources.download.minecraft.net") + path));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), path.toUtf8());
|
||||
}
|
||||
QCOMPARE(m_net.requests().size(), 5);
|
||||
}
|
||||
|
||||
void unknownRouteIs404AndRecorded()
|
||||
{
|
||||
QNetworkAccessManager manager;
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("FakeInternet: нет маршрута")));
|
||||
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("https://example.invalid/x")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 404);
|
||||
QCOMPARE(m_net.unmatched(), QStringList{QStringLiteral("https://example.invalid/x")});
|
||||
}
|
||||
|
||||
void errorStatusesAndDrop()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://a.test/500"), FakeResponse::error(500));
|
||||
m_net.route(QStringLiteral("https://a.test/drop"), [] {
|
||||
FakeResponse response;
|
||||
response.drop = true;
|
||||
return response;
|
||||
}());
|
||||
m_net.route(QStringLiteral("https://a.test/short"), [] {
|
||||
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
|
||||
response.truncateTo = 10;
|
||||
return response;
|
||||
}());
|
||||
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
||||
QVERIFY(waitFinished(r500.data()));
|
||||
QCOMPARE(r500->error(), QNetworkReply::InternalServerError);
|
||||
|
||||
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
||||
QVERIFY(waitFinished(drop.data()));
|
||||
QVERIFY(drop->error() != QNetworkReply::NoError);
|
||||
|
||||
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
||||
QVERIFY(waitFinished(shortReply.data()));
|
||||
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
||||
}
|
||||
|
||||
void plainHttpThroughProxy()
|
||||
{
|
||||
m_net.route(QStringLiteral("http://plain.test/hello"), FakeResponse::text("hi"));
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("http://plain.test/hello")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("hi"));
|
||||
QCOMPARE(m_net.requests().constFirst().scheme, QStringLiteral("http"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestFakeInternet)
|
||||
#include "tst_fakeinternet.moc"
|
||||
@@ -0,0 +1,292 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "javainstaller.h"
|
||||
#include "javalocator.h"
|
||||
#include "javaruntime.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kTemurin = QStringLiteral("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21/");
|
||||
const QString kMojangManifest = QStringLiteral("https://piston-meta.mojang.com/v1/packages/x/manifest.json");
|
||||
const QString kMojangFiles = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
|
||||
|
||||
JavaRuntimeEntry temurinEntry(const QString &archive, const QByteArray &data)
|
||||
{
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("temurin-jdk-21.0.2_13-test-") + QString(archive).replace(u'.', u'-');
|
||||
entry.kind = JavaRuntimeKind::Jdk;
|
||||
entry.major = 21;
|
||||
entry.version = QStringLiteral("21.0.2+13");
|
||||
entry.archive = archive;
|
||||
entry.url = QUrl(kTemurin + "OpenJDK21U-jdk." + archive);
|
||||
entry.checksum = Fixtures::sha256(data);
|
||||
entry.size = data.size();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Раскладка архива Temurin: один корневой каталог с bin/java внутри.
|
||||
QMap<QString, QByteArray> jdkTree()
|
||||
{
|
||||
return {{QStringLiteral("jdk-21.0.2+13/bin/") + Fixtures::javaExecutableName(), "#!/bin/sh\necho java\n"},
|
||||
{QStringLiteral("jdk-21.0.2+13/lib/modules"), QByteArray(2048, 'm')},
|
||||
{QStringLiteral("jdk-21.0.2+13/release"), "JAVA_VERSION=\"21.0.2\""}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestJavaInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_work;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(JavaInstaller &installer)
|
||||
: started(&installer, &JavaInstaller::started)
|
||||
, finished(&installer, &JavaInstaller::finished)
|
||||
, failed(&installer, &JavaInstaller::failed)
|
||||
, canceled(&installer, &JavaInstaller::canceled)
|
||||
{}
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
|
||||
QSignalSpy started, finished, failed, canceled;
|
||||
};
|
||||
|
||||
void verifyRegistered(const JavaRuntimeEntry &entry, const Run &run)
|
||||
{
|
||||
QCOMPARE(run.finished.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().at(0).toString(), entry.id);
|
||||
const QString javaPath = run.finished.constFirst().at(1).toString();
|
||||
QVERIFY(QFileInfo(javaPath).isFile());
|
||||
QVERIFY(javaPath.startsWith(JavaRuntimeStore::dirFor(entry.id) + u'/'));
|
||||
#if !defined(Q_OS_WIN)
|
||||
QVERIFY(QFileInfo(javaPath).isExecutable());
|
||||
#endif
|
||||
const auto installed = JavaRuntimeStore::find(entry.id);
|
||||
QVERIFY(installed.has_value());
|
||||
QCOMPARE(installed->major, entry.major);
|
||||
QCOMPARE(installed->version, entry.version);
|
||||
QCOMPARE(installed->kind, javaKindKey(entry.kind));
|
||||
// Лаунчер видит её при поиске Java.
|
||||
QStringList canonical;
|
||||
for (const QString &path : JavaLocator::findAll(m_work))
|
||||
canonical << QFileInfo(path).canonicalFilePath();
|
||||
QVERIFY(canonical.contains(QFileInfo(javaPath).canonicalFilePath()));
|
||||
// Скачанный архив за собой не оставляем.
|
||||
QVERIFY(QDir(LauncherPaths::javaDownloadDir()).entryList(QDir::Files).isEmpty());
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_work = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void installsZipArchive()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(installer.isRunning());
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
QVERIFY(!installer.isRunning());
|
||||
}
|
||||
|
||||
void installsTarGzArchive()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
QSKIP("Temurin под Windows — только zip; tar.gz распаковывается системным tar из PATH");
|
||||
#endif
|
||||
const QString archive = m_work + "/jdk.tar.gz";
|
||||
if (!Fixtures::makeTarGz(archive, jdkTree()))
|
||||
QSKIP("системный tar недоступен");
|
||||
const QByteArray data = Fixtures::readFile(archive);
|
||||
const JavaRuntimeEntry entry = temurinEntry("tar.gz", data);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(data));
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
}
|
||||
|
||||
void archiveChecksumMismatch()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
entry.checksum = QString(64, u'0');
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.archiveChecksum"));
|
||||
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
|
||||
}
|
||||
|
||||
void archiveWithoutJavaFails()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes({{"jdk/README", "no binaries here"}});
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.noExecutable"));
|
||||
QVERIFY(!JavaRuntimeStore::find(entry.id).has_value());
|
||||
}
|
||||
|
||||
void zipSlipIsRejected()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes({{"jdk/../../../escaped", "x"}});
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.archiveBadPath").arg("jdk/../../../escaped"));
|
||||
QVERIFY(!QFile::exists(LauncherPaths::rootDir() + "/escaped"));
|
||||
}
|
||||
|
||||
void unknownFormatAndInvalidEntry()
|
||||
{
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(JavaRuntimeEntry{});
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.buildUnavailable"));
|
||||
|
||||
JavaRuntimeEntry entry = temurinEntry("7z", "x");
|
||||
Run run2(installer);
|
||||
installer.install(entry);
|
||||
QCOMPARE(run2.failure(), Loc::text("java.error.unknownArchiveFormat").arg("7z"));
|
||||
}
|
||||
|
||||
void mojangComponent()
|
||||
{
|
||||
const QByteArray java = "#!/bin/sh\necho mojang-java\n";
|
||||
const QByteArray lib = QByteArray(1024, 'l');
|
||||
const QString binary = QStringLiteral("bin/") + Fixtures::javaExecutableName();
|
||||
QJsonObject files{
|
||||
{"bin", QJsonObject{{"type", "directory"}}},
|
||||
{binary, QJsonObject{{"type", "file"}, {"executable", true},
|
||||
{"downloads", QJsonObject{{"raw", QJsonObject{
|
||||
{"url", kMojangFiles + Fixtures::sha1(java) + "/java"},
|
||||
{"sha1", Fixtures::sha1(java)}, {"size", java.size()}}}}}}},
|
||||
{"lib/modules", QJsonObject{{"type", "file"},
|
||||
{"downloads", QJsonObject{{"raw", QJsonObject{
|
||||
{"url", kMojangFiles + Fixtures::sha1(lib) + "/modules"},
|
||||
{"sha1", Fixtures::sha1(lib)}, {"size", lib.size()}}}}}}},
|
||||
{"lib/modules-link", QJsonObject{{"type", "link"}, {"target", "modules"}}}};
|
||||
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{{"files", files}}));
|
||||
m_net.route(kMojangFiles + Fixtures::sha1(java) + "/java", FakeResponse::bytes(java));
|
||||
int libAttempts = 0;
|
||||
m_net.route(kMojangFiles + Fixtures::sha1(lib) + "/modules", [&](const FakeRequest &) {
|
||||
return ++libAttempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes(lib); // временный сбой
|
||||
});
|
||||
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("mojang-java-runtime-delta");
|
||||
entry.kind = JavaRuntimeKind::Mojang;
|
||||
entry.major = 21;
|
||||
entry.version = QStringLiteral("21.0.7");
|
||||
entry.component = QStringLiteral("java-runtime-delta");
|
||||
entry.archive = QStringLiteral("mojang");
|
||||
entry.url = QUrl(kMojangManifest);
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
QCOMPARE(libAttempts, 2);
|
||||
const QString dir = JavaRuntimeStore::dirFor(entry.id);
|
||||
QCOMPARE(Fixtures::readFile(dir + "/lib/modules"), lib);
|
||||
QVERIFY(QFileInfo::exists(dir + "/lib/modules-link"));
|
||||
}
|
||||
|
||||
void mojangManifestEscapingIsRejected()
|
||||
{
|
||||
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{
|
||||
{"files", QJsonObject{{"../../evil", QJsonObject{{"type", "directory"}}}}}}));
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("mojang-evil");
|
||||
entry.kind = JavaRuntimeKind::Mojang;
|
||||
entry.major = 17;
|
||||
entry.version = QStringLiteral("17");
|
||||
entry.archive = QStringLiteral("mojang");
|
||||
entry.url = QUrl(kMojangManifest);
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.manifestBadPath").arg("../../evil"));
|
||||
}
|
||||
|
||||
void cancelDuringDownload()
|
||||
{
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", "whatever");
|
||||
m_net.route(entry.url.toString(), hang);
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(entry.url.toString()), 1, 15000);
|
||||
installer.cancel();
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
// abort() синхронно доводит ответ до handleArchiveFinished(), который уже
|
||||
// шлёт canceled; затем cancel() видит m_reply == nullptr и шлёт его ещё раз.
|
||||
QEXPECT_FAIL("", "canceled приходит дважды: из handleArchiveFinished() и из cancel()", Continue);
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
}
|
||||
|
||||
void reinstallReplacesOld()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
Fixtures::write(JavaRuntimeStore::dirFor(entry.id) + "/stale/file", "old");
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QVERIFY(!QFile::exists(JavaRuntimeStore::dirFor(entry.id) + "/stale/file"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaInstaller)
|
||||
#include "tst_javainstaller.moc"
|
||||
@@ -0,0 +1,319 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
#include "javaruntimeservice.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
#include <QSysInfo>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kReleases = QStringLiteral("https://api.adoptium.net/v3/info/available_releases");
|
||||
const QString kFeature = QStringLiteral("https://api.adoptium.net/v3/assets/feature_releases/*");
|
||||
const QString kMojang = QStringLiteral(
|
||||
"https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json");
|
||||
|
||||
bool VersionLoaderOsIsWindows()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
QString hostAdoptiumArch()
|
||||
{
|
||||
const QString arch = QSysInfo::currentCpuArchitecture();
|
||||
if (arch == QLatin1String("arm64"))
|
||||
return QStringLiteral("aarch64");
|
||||
if (arch == QLatin1String("x86_64"))
|
||||
return QStringLiteral("x64");
|
||||
return arch;
|
||||
}
|
||||
|
||||
// Один релиз Temurin в формате Adoptium API.
|
||||
QJsonObject release(int major, const QString &arch, const QString &openjdk, int security,
|
||||
const QString ×tamp, const QString &os)
|
||||
{
|
||||
const QString ext = os == QLatin1String("windows") ? QStringLiteral("zip") : QStringLiteral("tar.gz");
|
||||
QJsonArray binaries;
|
||||
for (const QString type : {QStringLiteral("jdk"), QStringLiteral("jre"), QStringLiteral("testimage")}) {
|
||||
const QString name = QStringLiteral("OpenJDK%1U-%2_%3_%4_hotspot.%5").arg(major).arg(type, arch, os, ext);
|
||||
binaries << QJsonObject{
|
||||
{"architecture", arch}, {"image_type", type}, {"os", os},
|
||||
{"package", QJsonObject{{"name", name},
|
||||
{"link", "https://github.com/adoptium/temurin/releases/download/x/" + name},
|
||||
{"checksum", QString(64, u'c')},
|
||||
{"size", 190000000}}}};
|
||||
}
|
||||
return QJsonObject{
|
||||
{"release_name", QStringLiteral("jdk-%1").arg(openjdk)},
|
||||
{"timestamp", timestamp},
|
||||
{"binaries", binaries},
|
||||
{"version_data", QJsonObject{{"major", major}, {"security", security},
|
||||
{"openjdk_version", openjdk + "-LTS"}, {"semver", openjdk}}}};
|
||||
}
|
||||
|
||||
QByteArray mojangAll()
|
||||
{
|
||||
const QJsonObject components{
|
||||
{"java-runtime-delta", QJsonArray{QJsonObject{
|
||||
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/d/manifest.json"},
|
||||
{"sha1", "dddd"}, {"size", 1000}}},
|
||||
{"version", QJsonObject{{"name", "21.0.7"}, {"released", "2025-04-15T00:00:00+00:00"}}}}}},
|
||||
{"jre-legacy", QJsonArray{QJsonObject{
|
||||
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/l/manifest.json"},
|
||||
{"sha1", "llll"}, {"size", 1000}}},
|
||||
{"version", QJsonObject{{"name", "8u51"}, {"released", "2015-07-15T00:00:00+00:00"}}}}}},
|
||||
{"java-runtime-alpha", QJsonArray{}},
|
||||
{"minecraft-java-exe", QJsonArray{QJsonObject{
|
||||
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/e/manifest.json"}}},
|
||||
{"version", QJsonObject{{"name", "7.0"}}}}}},
|
||||
{"insecure", QJsonArray{QJsonObject{
|
||||
{"manifest", QJsonObject{{"url", "http://piston-meta.mojang.com/x.json"}}},
|
||||
{"version", QJsonObject{{"name", "17.0.1"}}}}}}};
|
||||
QJsonObject root;
|
||||
for (const char *platform : {"gamecore", "linux", "linux-i386", "mac-os", "mac-os-arm64",
|
||||
"windows-arm64", "windows-x64", "windows-x86"})
|
||||
root.insert(QLatin1String(platform), components);
|
||||
return QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestJavaRuntimeService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
// Отвечает релизами на запрошенную архитектуру/ОС; без «missingForArch» — для всех.
|
||||
void routeAdoptium(const QString &missingForArch = QString())
|
||||
{
|
||||
m_net.route(kReleases, FakeResponse::json(QJsonObject{
|
||||
{"available_lts_releases", QJsonArray{8, 17, 21}},
|
||||
{"available_releases", QJsonArray{8, 17, 21, 24}}}));
|
||||
m_net.route(kFeature, [missingForArch](const FakeRequest &request) {
|
||||
const int major = request.path.section(u'/', -2, -2).toInt();
|
||||
const QString arch = request.queryItems().queryItemValue("architecture");
|
||||
const QString os = request.queryItems().queryItemValue("os");
|
||||
if (arch == missingForArch)
|
||||
return FakeResponse::json("[]");
|
||||
QJsonArray list;
|
||||
if (major == 8) {
|
||||
list << release(8, arch, "1.8.0_452-b09", 452, "2025-04-20T00:00:00Z", os);
|
||||
} else {
|
||||
list << release(major, arch, QStringLiteral("%1.0.2+13").arg(major), 2, "2025-01-20T00:00:00Z", os)
|
||||
<< release(major, arch, QStringLiteral("%1.0.1+12").arg(major), 1, "2024-10-20T00:00:00Z", os);
|
||||
}
|
||||
return FakeResponse::json(QJsonDocument(list).toJson());
|
||||
});
|
||||
}
|
||||
|
||||
static bool load(JavaRuntimeService &service, CallResult &r, bool force = false)
|
||||
{
|
||||
service.ensureLoaded(r.callback(), force);
|
||||
return QTest::qWaitFor([&] { return r.calls > 0; }, 20000);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
}
|
||||
|
||||
void mergesAdoptiumAndMojang()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
|
||||
JavaRuntimeService service;
|
||||
QSignalSpy changed(&service, &JavaRuntimeService::catalogChanged);
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
QVERIFY(r.warning.isEmpty());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QVERIFY(!service.isRefreshing());
|
||||
|
||||
// Запросы к Adoptium — с параметрами текущей машины.
|
||||
const QList<FakeRequest> requests = m_net.requests();
|
||||
int featureRequests = 0;
|
||||
for (const FakeRequest &request : requests) {
|
||||
if (!request.path.startsWith("/v3/assets/feature_releases/"))
|
||||
continue;
|
||||
++featureRequests;
|
||||
QCOMPARE(request.queryItems().queryItemValue("architecture"), hostAdoptiumArch());
|
||||
QCOMPARE(request.queryItems().queryItemValue("image_type"), QString());
|
||||
QCOMPARE(request.queryItems().queryItemValue("vendor"), QStringLiteral("eclipse"));
|
||||
}
|
||||
QCOMPARE(featureRequests, 4);
|
||||
|
||||
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
|
||||
const QList<JavaRuntimeEntry> jres = service.entries(JavaRuntimeKind::Jre);
|
||||
QCOMPARE(jdks.size(), 7); // 8 — один релиз, 17/21/24 — по два
|
||||
QCOMPARE(jres.size(), 7);
|
||||
// Свежие major первыми, внутри — по дате.
|
||||
QCOMPARE(jdks.first().major, 24);
|
||||
QCOMPARE(jdks.first().version, QStringLiteral("24.0.2+13"));
|
||||
QCOMPARE(jdks.last().major, 8);
|
||||
QCOMPARE(jdks.last().version, QStringLiteral("8u452"));
|
||||
|
||||
const JavaRuntimeEntry jdk21 = jdks.at(2);
|
||||
QCOMPARE(jdk21.major, 21);
|
||||
QCOMPARE(jdk21.version, QStringLiteral("21.0.2+13")); // «-LTS» отрезан
|
||||
QVERIFY(jdk21.lts);
|
||||
QVERIFY(!jdks.first().lts); // 24 — не LTS
|
||||
QCOMPARE(jdk21.architecture, hostAdoptiumArch());
|
||||
QCOMPARE(jdk21.size, qint64(190000000));
|
||||
QCOMPARE(jdk21.archive, VersionLoaderOsIsWindows() ? QStringLiteral("zip") : QStringLiteral("tar.gz"));
|
||||
QCOMPARE(jdk21.id, JavaRuntimeStore::sanitizeId("temurin-jdk-21.0.2+13-" + hostAdoptiumArch()));
|
||||
QVERIFY(service.find(jdk21.id).has_value());
|
||||
|
||||
// Mojang: java-runtime-delta (21) и jre-legacy (8); пустые, exe и http — выброшены.
|
||||
const QList<JavaRuntimeEntry> mojang = service.entries(JavaRuntimeKind::Mojang);
|
||||
QCOMPARE(mojang.size(), 2);
|
||||
const auto delta = service.find("mojang-java-runtime-delta");
|
||||
QVERIFY(delta.has_value());
|
||||
QCOMPARE(delta->major, 21);
|
||||
QCOMPARE(delta->component, QStringLiteral("java-runtime-delta"));
|
||||
QCOMPARE(delta->archive, QStringLiteral("mojang"));
|
||||
QCOMPARE(delta->checksum, QStringLiteral("dddd"));
|
||||
QCOMPARE(service.find("mojang-jre-legacy")->major, 8);
|
||||
|
||||
QVERIFY(QFile::exists(LauncherPaths::javaCatalogFile()));
|
||||
}
|
||||
|
||||
void bestForPrefersSmallestSuitableMajorThenJdk()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
|
||||
QVERIFY(!service.bestFor(0).has_value());
|
||||
QVERIFY(!service.bestFor(99).has_value());
|
||||
const auto for17 = service.bestFor(17);
|
||||
QVERIFY(for17.has_value());
|
||||
QCOMPARE(for17->major, 17);
|
||||
QCOMPARE(for17->kind, JavaRuntimeKind::Jdk);
|
||||
QCOMPARE(for17->version, QStringLiteral("17.0.2+13")); // самый свежий релиз
|
||||
QCOMPARE(service.bestFor(18)->major, 21);
|
||||
QCOMPARE(service.bestFor(8)->major, 8);
|
||||
}
|
||||
|
||||
void partialFailureStillProducesCatalog()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::error(500));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("java.error.mojangRuntimesFailed").section(u'%', 0, 0)));
|
||||
QVERIFY(service.entries(JavaRuntimeKind::Mojang).isEmpty());
|
||||
QVERIFY(!service.entries(JavaRuntimeKind::Jdk).isEmpty());
|
||||
}
|
||||
|
||||
void adoptiumDownMojangUp()
|
||||
{
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Mojang).size(), 2);
|
||||
QVERIFY(service.entries(JavaRuntimeKind::Jdk).isEmpty());
|
||||
}
|
||||
|
||||
void everythingDownWithoutCacheFails()
|
||||
{
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.contains("; ")); // оба предупреждения склеены
|
||||
QVERIFY(!service.hasData());
|
||||
}
|
||||
|
||||
void everythingDownWithCacheUsesCache()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
{
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
}
|
||||
m_net.reset();
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
|
||||
JavaRuntimeService service;
|
||||
CallResult cached;
|
||||
QVERIFY(load(service, cached));
|
||||
QVERIFY(cached.ok);
|
||||
QVERIFY(m_net.requests().isEmpty()); // кеш свежий — сети нет
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
|
||||
|
||||
CallResult forced;
|
||||
QVERIFY(load(service, forced, true));
|
||||
QVERIFY(forced.ok);
|
||||
QVERIFY(!forced.warning.isEmpty());
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
|
||||
}
|
||||
|
||||
void cacheForOtherPlatformIsIgnored()
|
||||
{
|
||||
QJsonObject entry;
|
||||
entry["id"] = "x";
|
||||
entry["kind"] = "jdk";
|
||||
entry["major"] = 17;
|
||||
entry["url"] = "https://x/y.zip";
|
||||
QJsonObject cache;
|
||||
cache["platform"] = "amiga-os";
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
cache["entries"] = QJsonArray{entry};
|
||||
Fixtures::writeJson(LauncherPaths::javaCatalogFile(), cache);
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(!service.hasData());
|
||||
QVERIFY(!r.ok);
|
||||
}
|
||||
|
||||
void aarch64FallsBackToX64()
|
||||
{
|
||||
if (hostAdoptiumArch() != QLatin1String("aarch64"))
|
||||
QSKIP("запасной x64 запрашивается только на ARM-машинах");
|
||||
routeAdoptium(QStringLiteral("aarch64"));
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
|
||||
QVERIFY(!jdks.isEmpty());
|
||||
for (const JavaRuntimeEntry &entry : jdks)
|
||||
QCOMPARE(entry.architecture, QStringLiteral("x64"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaRuntimeService)
|
||||
#include "tst_javaruntimeservice.moc"
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "testenv.h"
|
||||
#include "fakemojang.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
#include "modloaderinstaller.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
#include "zlibreference.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kFabricList = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1/0.16.10/profile/json");
|
||||
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
|
||||
const QString kForgeInstaller = QStringLiteral(
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar");
|
||||
|
||||
QByteArray fabricProfile(const QString &inherits = QStringLiteral("1.20.1"))
|
||||
{
|
||||
// Профиль Fabric: дочерняя версия с собственной библиотекой из Maven Fabric.
|
||||
const QJsonObject json{
|
||||
{"id", "fabric-loader-0.16.10-1.20.1"},
|
||||
{"inheritsFrom", inherits},
|
||||
{"type", "release"},
|
||||
{"mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient"},
|
||||
{"arguments", QJsonObject{{"game", QJsonArray{}}, {"jvm", QJsonArray{"-DFabricMcEmu= net.minecraft.client.main.Main "}}}},
|
||||
{"libraries", QJsonArray{QJsonObject{{"name", "net.fabricmc:fabric-loader:0.16.10"},
|
||||
{"url", "https://maven.fabricmc.net/"}}}}};
|
||||
return QJsonDocument(json).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestModLoaderInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_game;
|
||||
|
||||
struct Rig
|
||||
{
|
||||
VersionManifestService manifest;
|
||||
ModLoaderVersionService meta;
|
||||
VersionInstaller versions{&manifest};
|
||||
ModLoaderInstaller installer{&meta, &versions};
|
||||
QSignalSpy finished{&installer, &ModLoaderInstaller::finished};
|
||||
QSignalSpy failed{&installer, &ModLoaderInstaller::failed};
|
||||
QSignalSpy canceled{&installer, &ModLoaderInstaller::canceled};
|
||||
QSignalSpy log{&installer, &ModLoaderInstaller::log};
|
||||
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
|
||||
};
|
||||
|
||||
void routeForge()
|
||||
{
|
||||
m_net.route(kForgeMeta, FakeResponse::bytes(Fixtures::read("loaders/forge-maven-metadata.xml")));
|
||||
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
|
||||
m_net.route(kForgeInstaller, FakeResponse::bytes("PK fake forge installer"));
|
||||
}
|
||||
|
||||
// Подставная java, которая ведёт себя как установщик Forge: кладёт профиль версии.
|
||||
QString forgeLikeJava(const QStringList &extra = {})
|
||||
{
|
||||
const QString id = QStringLiteral("1.20.1-forge-47.4.0");
|
||||
const QString json = QStringLiteral(
|
||||
"{\"id\":\"%1\",\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"cpw.mods.bootstraplauncher.BootstrapLauncher\"}").arg(id);
|
||||
QStringList config{"args_file=" + m_game + "/installer-args.txt",
|
||||
"dump_env=LD_PRELOAD",
|
||||
"stdout=Extracting json",
|
||||
"stdout=The client installed successfully"};
|
||||
config << "create=" + m_game + "/versions/" + id + u'/' + id + ".json\t" + json;
|
||||
config += extra;
|
||||
return Fixtures::installFakeJava(m_game + "/jdk17", "17.0.8", config);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
m_net.route(kFabricList, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
|
||||
m_net.route("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar",
|
||||
FakeResponse::bytes("fabric-loader-jar"));
|
||||
}
|
||||
|
||||
void fabricInstallsBaseThenProfile()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile()));
|
||||
Rig rig;
|
||||
QSignalSpy started(&rig.installer, &ModLoaderInstaller::started);
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.installer.isRunning());
|
||||
QCOMPARE(started.count(), 1);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
|
||||
const QList<QVariant> done = rig.finished.constFirst();
|
||||
QCOMPARE(done.at(0).toString(), QStringLiteral("fabric"));
|
||||
QCOMPARE(done.at(1).toString(), QStringLiteral("1.20.1"));
|
||||
QCOMPARE(done.at(2).toString(), QStringLiteral("0.16.10"));
|
||||
QCOMPARE(done.at(3).toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
|
||||
// Ваниль докачана, профиль сохранён, библиотека загрузчика на месте.
|
||||
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
|
||||
QVERIFY(QFile::exists(m_game + "/versions/fabric-loader-0.16.10-1.20.1/fabric-loader-0.16.10-1.20.1.json"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"),
|
||||
QByteArray("fabric-loader-jar"));
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.16.10-1.20.1", {}, nullptr);
|
||||
QVERIFY(v.isValid());
|
||||
}
|
||||
|
||||
void fabricProfileForWrongGameIsRejected()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile("1.19.4")));
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionWrongMinecraft")
|
||||
.arg(loaderTitle(ModLoader::Fabric), "1.19.4", "1.20.1"));
|
||||
QVERIFY(!QDir(m_game + "/versions/fabric-loader-0.16.10-1.20.1").exists());
|
||||
}
|
||||
|
||||
void fabricProfileWithUnsafeIdIsRejected()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(QJsonObject{{"id", "../evil"}, {"inheritsFrom", "1.20.1"}}));
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionBadId").arg(loaderTitle(ModLoader::Fabric)));
|
||||
}
|
||||
|
||||
void validationErrors()
|
||||
{
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", " ", QString());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.noVersionSelected").arg(loaderTitle(ModLoader::Fabric)));
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
}
|
||||
|
||||
void secondInstallWhileBusy()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(fabricProfile());
|
||||
slow.delayMs = 500;
|
||||
m_net.route(kFabricProfile, slow);
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
rig.installer.install(m_game, ModLoader::Quilt, "1.20.1", "0.26.3", QString());
|
||||
QCOMPARE(rig.failed.count(), 1);
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.installInProgress"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(rig.finished.count(), 1, 30000);
|
||||
}
|
||||
|
||||
void forgeRunsInstallerWithJava()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
|
||||
QCOMPARE(rig.finished.constFirst().at(3).toString(), QStringLiteral("1.20.1-forge-47.4.0"));
|
||||
|
||||
// Установщик запущен как java -jar <installer> --installClient <папка игры>.
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
|
||||
.split(u'\n', Qt::SkipEmptyParts);
|
||||
QCOMPARE(args.value(0), QStringLiteral("-jar"));
|
||||
QCOMPARE(QFileInfo(args.value(1)).fileName(), QStringLiteral("forge-47.4.0-installer.jar"));
|
||||
QCOMPARE(args.value(2), QStringLiteral("--installClient"));
|
||||
QCOMPARE(args.value(3), QDir::toNativeSeparators(m_game));
|
||||
// Заглушка launcher_profiles.json, без которой установщик Forge отказывается работать.
|
||||
QVERIFY(Fixtures::readJsonFile(m_game + "/launcher_profiles.json").contains("profiles"));
|
||||
// Вывод установщика идёт в журнал.
|
||||
QStringList lines;
|
||||
for (const auto &call : rig.log)
|
||||
lines << call.constFirst().toString();
|
||||
QVERIFY(lines.contains("The client installed successfully"));
|
||||
QVERIFY(QFile::exists(LauncherPaths::loaderDownloadDir() + "/forge-47.4.0-install.log"));
|
||||
}
|
||||
|
||||
void forgeInstallerFailureCleansUp()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava({"exit=1", "stdout=Processor failed, invalid outputs"});
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
const QString failure = rig.failure();
|
||||
QVERIFY(failure.startsWith(Loc::text("loader.error.installerExitCode").arg(loaderTitle(ModLoader::Forge)).arg(1)));
|
||||
QVERIFY(failure.contains("Processor failed")); // хвост лога в сообщении
|
||||
// Версию, которую успел создать упавший установщик, убираем.
|
||||
QVERIFY(!QDir(m_game + "/versions/1.20.1-forge-47.4.0").exists());
|
||||
QVERIFY(QDir(m_game + "/versions/1.20.1").exists());
|
||||
}
|
||||
|
||||
void forgeInstallerProducingNothing()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = Fixtures::installFakeJava(m_game + "/jdk17-empty", "17.0.8", {"stdout=did nothing"});
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY(rig.failure().startsWith(Loc::text("loader.error.noVersionProduced").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void forgeUnknownVersionIsIncompatible()
|
||||
{
|
||||
routeForge();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "1.0.0", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.incompatible").arg(loaderTitle(ModLoader::Forge), "1.0.0", "1.20.1"));
|
||||
}
|
||||
|
||||
void zlibNgEnvironmentOnLinux()
|
||||
{
|
||||
#if !defined(Q_OS_LINUX)
|
||||
QSKIP("LD_PRELOAD с эталонным zlib — только Linux");
|
||||
#else
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
|
||||
.split(u'\n', Qt::SkipEmptyParts);
|
||||
const QString preload = args.filter("env:LD_PRELOAD=").value(0).mid(int(sizeof("env:LD_PRELOAD=") - 1));
|
||||
if (ZlibReference::systemIsZlibNg() && !ZlibReference::bundledPath().isEmpty())
|
||||
QVERIFY(preload.startsWith(ZlibReference::bundledPath()));
|
||||
else
|
||||
QCOMPARE(preload, qEnvironmentVariable("LD_PRELOAD"));
|
||||
#endif
|
||||
}
|
||||
|
||||
void cancelDuringInstallerDownload()
|
||||
{
|
||||
routeForge();
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
m_net.route(kForgeInstaller, hang);
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", QString());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kForgeInstaller), 1, 30000);
|
||||
rig.installer.cancel();
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.canceled.count(), 1);
|
||||
QVERIFY(rig.failed.isEmpty());
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestModLoaderInstaller)
|
||||
#include "tst_modloaderinstaller.moc"
|
||||
@@ -0,0 +1,274 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "modloaderversionservice.h"
|
||||
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||
const QString kQuilt = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/1.20.1");
|
||||
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
|
||||
const QString kNeoMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
|
||||
const QString kNeoLegacyMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml");
|
||||
|
||||
FakeResponse xml(const QByteArray &body)
|
||||
{
|
||||
FakeResponse response = FakeResponse::bytes(body);
|
||||
response.contentType = "application/xml";
|
||||
return response;
|
||||
}
|
||||
|
||||
QStringList loaderVersions(const QList<LoaderVersionEntry> &list)
|
||||
{
|
||||
QStringList out;
|
||||
for (const LoaderVersionEntry &entry : list)
|
||||
out << entry.loaderVersion;
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class TestModLoaderVersionService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
void routeAll()
|
||||
{
|
||||
m_net.route(kFabric, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
|
||||
m_net.route(kQuilt, FakeResponse::json(Fixtures::read("loaders/quilt-1.20.1.json")));
|
||||
m_net.route(kForgeMeta, xml(Fixtures::read("loaders/forge-maven-metadata.xml")));
|
||||
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
|
||||
m_net.route(kNeoMeta, xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")));
|
||||
m_net.route(kNeoLegacyMeta, xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")));
|
||||
}
|
||||
|
||||
static bool load(ModLoaderVersionService &service, ModLoader loader, const QString &game,
|
||||
CallResult &r, bool force = false)
|
||||
{
|
||||
service.ensureLoaded(loader, game, r.callback(), force);
|
||||
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
routeAll();
|
||||
}
|
||||
|
||||
void fabric()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
QSignalSpy changed(&service, &ModLoaderVersionService::versionsChanged);
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Fabric, "1.20.1");
|
||||
// Новые первыми: 0.16.10 > 0.16.9 (числовое сравнение, не строковое); пустые версии выброшены.
|
||||
QCOMPARE(loaderVersions(list), QStringList({"0.17.0-beta.1", "0.16.10", "0.16.9"}));
|
||||
QCOMPARE(list.at(1).versionId, QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QCOMPARE(list.at(1).gameVersion, QStringLiteral("1.20.1"));
|
||||
QVERIFY(list.at(1).stable);
|
||||
QVERIFY(!list.at(0).stable);
|
||||
QVERIFY(list.at(1).installerUrl.isEmpty());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QCOMPARE(changed.constFirst().at(0).toString(), QStringLiteral("fabric"));
|
||||
QCOMPARE(changed.constFirst().at(1).toString(), QStringLiteral("1.20.1"));
|
||||
|
||||
QVERIFY(service.find(ModLoader::Fabric, "1.20.1", "0.16.9").has_value());
|
||||
QVERIFY(!service.find(ModLoader::Fabric, "1.20.1", "9.9.9").has_value());
|
||||
QVERIFY(service.versions(ModLoader::Fabric, "1.19.2").isEmpty());
|
||||
QVERIFY(QFile::exists(LauncherPaths::loaderCacheFile("fabric")));
|
||||
}
|
||||
|
||||
void quiltStabilityHeuristic()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Quilt, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Quilt, "1.20.1");
|
||||
QCOMPARE(loaderVersions(list), QStringList({"0.26.4-beta.1", "0.26.3"}));
|
||||
QCOMPARE(list.at(0).versionId, QStringLiteral("quilt-loader-0.26.4-beta.1-1.20.1"));
|
||||
QVERIFY(!list.at(0).stable); // поля stable нет — решает «beta» в номере
|
||||
QVERIFY(list.at(1).stable);
|
||||
}
|
||||
|
||||
void forgeMergesPromotions()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
QCOMPARE(m_net.hits(kForgeMeta), 1);
|
||||
QCOMPARE(m_net.hits(kForgePromos), 1);
|
||||
|
||||
const QList<LoaderVersionEntry> v1201 = service.versions(ModLoader::Forge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(v1201), QStringList({"47.4.1", "47.4.0", "47.3.0"}));
|
||||
for (const LoaderVersionEntry &entry : v1201)
|
||||
QCOMPARE(entry.recommended, entry.loaderVersion == QLatin1String("47.4.0"));
|
||||
QCOMPARE(v1201.at(1).installerUrl,
|
||||
QUrl("https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar"));
|
||||
QVERIFY(v1201.at(1).versionId.isEmpty()); // id Forge узнаём только после установки
|
||||
|
||||
// Весь каталог Forge — одним запросом, по всем версиям игры.
|
||||
const QList<LoaderVersionEntry> v1211 = service.versions(ModLoader::Forge, "1.21.1");
|
||||
QCOMPARE(loaderVersions(v1211), QStringList({"52.0.10", "52.0.2-beta"}));
|
||||
QVERIFY(!v1211.at(1).stable);
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::Forge, "1.7.10")),
|
||||
QStringList{"10.13.4.1614-1.7.10"});
|
||||
|
||||
// Другая версия игры у Forge не требует нового запроса.
|
||||
CallResult again;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.21.1", again));
|
||||
QCOMPARE(m_net.hits(kForgeMeta), 1);
|
||||
}
|
||||
|
||||
void forgeWithoutPromotionsStillWorks()
|
||||
{
|
||||
m_net.route(kForgePromos, FakeResponse::error(500));
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
for (const LoaderVersionEntry &entry : service.versions(ModLoader::Forge, "1.20.1"))
|
||||
QVERIFY(!entry.recommended);
|
||||
}
|
||||
|
||||
void neoForgeBothSchemes()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21.1")),
|
||||
QStringList({"21.1.248", "21.1.200"}));
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21")), QStringList{"21.0.10"});
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.20.4")),
|
||||
QStringList({"20.4.237", "20.4.80-beta"}));
|
||||
// Новая нумерация (год.выпуск.патч.сборка).
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1")), QStringList{"26.1.0.5"});
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1.1")), QStringList{"26.1.1.3"});
|
||||
QCOMPARE(service.find(ModLoader::NeoForge, "1.21.1", "21.1.248")->installerUrl,
|
||||
QUrl("https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar"));
|
||||
}
|
||||
|
||||
void neoForgeLegacyArtifactArrivesLast()
|
||||
{
|
||||
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
||||
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||
legacySlow.delayMs = 400;
|
||||
m_net.route(kNeoLegacyMeta, legacySlow);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
|
||||
QCOMPARE(legacy.at(0).installerUrl,
|
||||
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
|
||||
}
|
||||
|
||||
void neoForgeLegacyArtifactArrivesFirst()
|
||||
{
|
||||
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
|
||||
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||
primarySlow.delayMs = 400;
|
||||
m_net.route(kNeoMeta, primarySlow);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
||||
// В startRefresh() основной ответ делает state->merged = parsed и затирает
|
||||
// уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают.
|
||||
QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue);
|
||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
||||
}
|
||||
|
||||
void primaryFailureIsError()
|
||||
{
|
||||
m_net.route(kNeoMeta, FakeResponse::error(503));
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.contains(loaderTitle(ModLoader::NeoForge)));
|
||||
|
||||
m_net.route(kForgeMeta, xml("<metadata><versioning><versions></versions></versioning></metadata>"));
|
||||
CallResult empty;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", empty));
|
||||
QVERIFY(!empty.ok);
|
||||
QCOMPARE(empty.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Forge)));
|
||||
|
||||
m_net.route(kFabric, FakeResponse::json("{\"not\": \"an array\"}"));
|
||||
CallResult notArray;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", notArray));
|
||||
QVERIFY(!notArray.ok);
|
||||
QCOMPARE(notArray.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Fabric)));
|
||||
}
|
||||
|
||||
void cachePerLoaderAndOfflineFallback()
|
||||
{
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
|
||||
}
|
||||
QCOMPARE(m_net.hits(kFabric), 1);
|
||||
|
||||
ModLoaderVersionService service;
|
||||
CallResult cached;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", cached));
|
||||
QVERIFY(cached.ok);
|
||||
QCOMPARE(m_net.hits(kFabric), 1); // свежий кеш
|
||||
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
|
||||
|
||||
// Сеть легла, принудительное обновление: данные остаются, ok с предупреждением.
|
||||
m_net.route(kFabric, FakeResponse::error(502));
|
||||
CallResult offline;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", offline, true));
|
||||
QVERIFY(offline.ok);
|
||||
QVERIFY(!offline.warning.isEmpty());
|
||||
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
|
||||
}
|
||||
|
||||
void emptyGameVersionIsNoop()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, " ", r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void inFlightRequestsAreShared()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json"));
|
||||
slow.delayMs = 300;
|
||||
m_net.route(kFabric, slow);
|
||||
ModLoaderVersionService service;
|
||||
CallResult a, b;
|
||||
service.ensureLoaded(ModLoader::Fabric, "1.20.1", a.callback());
|
||||
QVERIFY(service.isRefreshing(ModLoader::Fabric, "1.20.1"));
|
||||
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.19.2"));
|
||||
service.ensureLoaded(ModLoader::Fabric, "1.20.1", b.callback());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1, 15000);
|
||||
QCOMPARE(m_net.hits(kFabric), 1);
|
||||
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.20.1"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestModLoaderVersionService)
|
||||
#include "tst_modloaderversionservice.moc"
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "testenv.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
#include "msaauthservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kToken = QStringLiteral("https://login.live.com/oauth20_token.srf");
|
||||
const QString kXbl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
|
||||
const QString kXsts = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
|
||||
const QString kMcLogin = QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
|
||||
const QString kEntitlements = QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
|
||||
const QString kProfile = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
|
||||
|
||||
QJsonObject jsonBody(const FakeRequest &request)
|
||||
{
|
||||
return QJsonDocument::fromJson(request.body).object();
|
||||
}
|
||||
|
||||
struct Captured
|
||||
{
|
||||
int calls = 0;
|
||||
AuthResult result;
|
||||
auto callback()
|
||||
{
|
||||
return [this](const AuthResult &r) { ++calls; result = r; };
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestMsaAuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
// Полностью рабочая цепочка Microsoft → Xbox → XSTS → Minecraft.
|
||||
void routeHappyPath()
|
||||
{
|
||||
m_net.route(kToken, [](const FakeRequest &request) {
|
||||
const QUrlQuery form = request.formItems();
|
||||
const QString grant = form.queryItemValue("grant_type");
|
||||
if (grant == QLatin1String("authorization_code") && form.queryItemValue("code") == QLatin1String("good-code"))
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-2"}});
|
||||
if (grant == QLatin1String("refresh_token") && form.queryItemValue("refresh_token") == QLatin1String("ms-refresh-1"))
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-3"}});
|
||||
return FakeResponse::json(QJsonObject{{"error", "invalid_grant"},
|
||||
{"error_description", "The provided value for the 'code' parameter is not valid."}}, 400);
|
||||
});
|
||||
m_net.route(kXbl, [](const FakeRequest &request) {
|
||||
const QJsonObject props = jsonBody(request).value("Properties").toObject();
|
||||
if (props.value("RpsTicket").toString() != QLatin1String("ms-access"))
|
||||
return FakeResponse::error(400);
|
||||
return FakeResponse::json(QJsonObject{{"Token", "xbl-token"}});
|
||||
});
|
||||
m_net.route(kXsts, [](const FakeRequest &request) {
|
||||
const QJsonObject props = jsonBody(request).value("Properties").toObject();
|
||||
if (props.value("UserTokens").toArray().first().toString() != QLatin1String("xbl-token"))
|
||||
return FakeResponse::error(400);
|
||||
const QJsonObject claims{{"xui", QJsonArray{QJsonObject{{"uhs", "user-hash"}, {"xid", "2535400000000000"}}}}};
|
||||
return FakeResponse::json(QJsonObject{{"Token", "xsts-token"}, {"DisplayClaims", claims}});
|
||||
});
|
||||
m_net.route(kMcLogin, [](const FakeRequest &request) {
|
||||
if (jsonBody(request).value("identityToken").toString() != QLatin1String("XBL3.0 x=user-hash;xsts-token"))
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "mc-token"}, {"expires_in", 3600}});
|
||||
});
|
||||
m_net.route(kEntitlements, [](const FakeRequest &request) {
|
||||
if (request.header("authorization") != "Bearer mc-token")
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "game_minecraft"}}}}});
|
||||
});
|
||||
m_net.route(kProfile, [](const FakeRequest &request) {
|
||||
if (request.header("authorization") != "Bearer mc-token")
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"id", "069a79f4-44e9-4726-a5be-fca90e38aaf5"}, {"name", "Notch"}});
|
||||
});
|
||||
}
|
||||
|
||||
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
routeHappyPath();
|
||||
}
|
||||
|
||||
void fullChainWithCode()
|
||||
{
|
||||
MsaAuthService service;
|
||||
QSignalSpy progress(&service, &MsaAuthService::progress);
|
||||
Captured c;
|
||||
const QDateTime before = QDateTime::currentDateTimeUtc();
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("Notch"));
|
||||
QCOMPARE(c.result.uuid, QStringLiteral("069a79f444e94726a5befca90e38aaf5"));
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("mc-token"));
|
||||
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-2"));
|
||||
QCOMPARE(c.result.xuid, QStringLiteral("2535400000000000"));
|
||||
QCOMPARE(c.result.userType, QStringLiteral("msa"));
|
||||
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
|
||||
QVERIFY(!c.result.licenseMissing);
|
||||
const qint64 lifetime = before.secsTo(c.result.expiresAt);
|
||||
QVERIFY(lifetime > 3500 && lifetime <= 3600);
|
||||
QVERIFY(progress.count() >= 5);
|
||||
|
||||
// Порядок шагов и формат запроса токена.
|
||||
QCOMPARE(m_net.requestedUrls(), QStringList({kToken, kXbl, kXsts, kMcLogin, kEntitlements, kProfile}));
|
||||
const FakeRequest tokenRequest = m_net.requests().constFirst();
|
||||
QCOMPARE(tokenRequest.header("content-type"), QByteArray("application/x-www-form-urlencoded"));
|
||||
QCOMPARE(tokenRequest.formItems().queryItemValue("client_id"), MsaAuthService::clientId());
|
||||
QCOMPARE(jsonBody(m_net.requests().at(1)).value("RelyingParty").toString(), QStringLiteral("http://auth.xboxlive.com"));
|
||||
QCOMPARE(jsonBody(m_net.requests().at(2)).value("RelyingParty").toString(), QStringLiteral("rp://api.minecraftservices.com/"));
|
||||
QCOMPARE(jsonBody(m_net.requests().at(2)).value("Properties").toObject().value("SandboxId").toString(),
|
||||
QStringLiteral("RETAIL"));
|
||||
}
|
||||
|
||||
void refreshTokenLogin()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithRefreshToken("ms-refresh-1", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-3")); // новый refresh-токен сохраняется
|
||||
QCOMPARE(m_net.requests().constFirst().formItems().queryItemValue("grant_type"), QStringLiteral("refresh_token"));
|
||||
}
|
||||
|
||||
void emptyInputsFailImmediately()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured noCode;
|
||||
service.loginWithCode(QString(), noCode.callback());
|
||||
QCOMPARE(noCode.calls, 1);
|
||||
QCOMPARE(noCode.result.error, Loc::text("auth.msa.error.noAuthCode"));
|
||||
Captured noRefresh;
|
||||
service.loginWithRefreshToken(QString(), noRefresh.callback());
|
||||
QCOMPARE(noRefresh.calls, 1);
|
||||
QCOMPARE(noRefresh.result.error, Loc::text("auth.msa.error.profileNotLinked"));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void badCodeShowsMicrosoftDescription()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("bad-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, QStringLiteral("The provided value for the 'code' parameter is not valid."));
|
||||
QCOMPARE(m_net.requests().size(), 1);
|
||||
}
|
||||
|
||||
void xstsErrors_data()
|
||||
{
|
||||
QTest::addColumn<double>("xerr");
|
||||
QTest::addColumn<QString>("message");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("no xbox profile") << 2148916233.0 << "" << Loc::text("auth.msa.error.noXboxProfile");
|
||||
QTest::newRow("country") << 2148916235.0 << "" << Loc::text("auth.msa.error.countryBlocked");
|
||||
QTest::newRow("age 36") << 2148916236.0 << "" << Loc::text("auth.msa.error.ageVerification");
|
||||
QTest::newRow("age 37") << 2148916237.0 << "" << Loc::text("auth.msa.error.ageVerification");
|
||||
QTest::newRow("child") << 2148916238.0 << "" << Loc::text("auth.msa.error.childAccount");
|
||||
QTest::newRow("unknown with message") << 1.0 << "Something odd" << "Something odd";
|
||||
QTest::newRow("unknown without message") << 7.0 << "" << Loc::text("auth.msa.error.xboxRejected").arg(7);
|
||||
}
|
||||
|
||||
void xstsErrors()
|
||||
{
|
||||
QFETCH(double, xerr);
|
||||
QFETCH(QString, message);
|
||||
QFETCH(QString, expected);
|
||||
m_net.route(kXsts, FakeResponse::json(QJsonObject{{"XErr", xerr}, {"Message", message}}, 401));
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, expected);
|
||||
}
|
||||
|
||||
void stepFailures_data()
|
||||
{
|
||||
QTest::addColumn<QString>("url");
|
||||
QTest::addColumn<int>("status");
|
||||
QTest::addColumn<QByteArray>("body");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("token without access_token") << kToken << 200 << QByteArray("{}") << Loc::text("auth.msa.error.noAccessToken");
|
||||
QTest::newRow("xbl rejected") << kXbl << 400 << QByteArray("{}") << Loc::text("auth.msa.error.xboxTokenRejected").arg(400);
|
||||
QTest::newRow("xbl no token") << kXbl << 200 << QByteArray("{}") << Loc::text("auth.msa.error.xboxNoToken");
|
||||
QTest::newRow("xsts 500") << kXsts << 500 << QByteArray("{}") << Loc::text("auth.msa.error.xstsRejected").arg(500);
|
||||
QTest::newRow("xsts no user") << kXsts << 200 << QByteArray("{\"Token\":\"t\"}") << Loc::text("auth.msa.error.xstsNoUser");
|
||||
QTest::newRow("services rejected") << kMcLogin << 429 << QByteArray("{}") << Loc::text("auth.msa.error.servicesRejected").arg(429);
|
||||
QTest::newRow("services no token") << kMcLogin << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoToken");
|
||||
QTest::newRow("profile 500") << kProfile << 500 << QByteArray("{}") << Loc::text("auth.msa.error.profileFetchFailed").arg(500);
|
||||
QTest::newRow("profile empty") << kProfile << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoProfile");
|
||||
}
|
||||
|
||||
void stepFailures()
|
||||
{
|
||||
QFETCH(QString, url);
|
||||
QFETCH(int, status);
|
||||
QFETCH(QByteArray, body);
|
||||
QFETCH(QString, expected);
|
||||
m_net.route(url, FakeResponse::json(body, status));
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, expected);
|
||||
}
|
||||
|
||||
void licenseChecks()
|
||||
{
|
||||
// Профиля нет, покупки тоже нет — нет лицензии.
|
||||
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{}}}));
|
||||
m_net.route(kProfile, FakeResponse::json("{}", 404));
|
||||
MsaAuthService service;
|
||||
Captured noLicense;
|
||||
service.loginWithCode("good-code", noLicense.callback());
|
||||
QVERIFY(wait(noLicense));
|
||||
QVERIFY(!noLicense.result.ok);
|
||||
QVERIFY(noLicense.result.licenseMissing);
|
||||
QCOMPARE(noLicense.result.error, Loc::text("auth.msa.error.noLicence"));
|
||||
|
||||
// Покупка есть, но профиль (ник) ещё не создан.
|
||||
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "x"}}}}}));
|
||||
Captured noProfile;
|
||||
service.loginWithCode("good-code", noProfile.callback());
|
||||
QVERIFY(wait(noProfile));
|
||||
QVERIFY(noProfile.result.licenseMissing);
|
||||
QCOMPARE(noProfile.result.error, Loc::text("auth.msa.error.noProfileYet"));
|
||||
}
|
||||
|
||||
void transportErrorAtMicrosoft()
|
||||
{
|
||||
FakeResponse drop;
|
||||
drop.drop = true;
|
||||
m_net.route(kToken, drop);
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.error.startsWith(Loc::text("auth.msa.error.microsoftUnreachable").section(u'%', 0, 0)));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestMsaAuthService)
|
||||
#include "tst_msaauthservice.moc"
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QUrl kBase(QStringLiteral("https://launcher-builds.galeonworx.com"));
|
||||
const QString kIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
|
||||
}
|
||||
|
||||
class TestSeasonalBuildService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
}
|
||||
|
||||
void withoutBaseUrlFails()
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("seasonal.error.noServerUrl"));
|
||||
QCOMPARE(service.lastError(), Loc::text("seasonal.error.noServerUrl"));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void parsesValidatesAndSorts()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
QCOMPARE(service.baseUrl(), kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
QStringList ids;
|
||||
for (const SeasonalBuildEntry &entry : service.builds())
|
||||
ids << entry.id;
|
||||
// Открытые сезоны первыми (новые выше), закрытые — после; всё невалидное отброшено.
|
||||
QCOMPARE(ids, QStringList({"season-5", "vanilla-plus", "season-4"}));
|
||||
|
||||
const auto s5 = service.find("season-5");
|
||||
QVERIFY(s5.has_value());
|
||||
QVERIFY(s5->isValid());
|
||||
QCOMPARE(s5->name, QStringLiteral("Сезон 5: Пустоши"));
|
||||
QCOMPARE(s5->revision, 3);
|
||||
QCOMPARE(s5->minecraftVersion, QStringLiteral("1.21.1"));
|
||||
QCOMPARE(s5->loader, QStringLiteral("fabric"));
|
||||
QCOMPARE(s5->loaderVersion, QStringLiteral("0.16.10"));
|
||||
QCOMPARE(s5->javaMajor, 21);
|
||||
QCOMPARE(s5->modCount, 120);
|
||||
QCOMPARE(s5->description, QStringLiteral("Пустоши")); // обрезаны пробелы
|
||||
QCOMPARE(s5->seasonStart, QDate(2025, 7, 1));
|
||||
QVERIFY(!s5->seasonEnd.isValid());
|
||||
QCOMPARE(s5->archiveSize, qint64(123456));
|
||||
QCOMPARE(s5->archiveSha256, QString("abcdef" + QString(58, u'0'))); // к нижнему регистру
|
||||
QCOMPARE(s5->archiveUrl, QUrl("https://launcher-builds.galeonworx.com/packs/s5.zip"));
|
||||
QCOMPARE(service.find("season-4")->seasonEnd, QDate(2025, 6, 1));
|
||||
QVERIFY(service.find("vanilla-plus")->loader.isEmpty());
|
||||
|
||||
QVERIFY(QFile::exists(LauncherPaths::seasonalCatalogFile()));
|
||||
}
|
||||
|
||||
void baseUrlPathIsKept()
|
||||
{
|
||||
m_net.route("https://launcher-builds.galeonworx.com/beta/index.json",
|
||||
FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{}}}));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(QUrl("https://launcher-builds.galeonworx.com/beta"));
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits("https://launcher-builds.galeonworx.com/beta/index.json"), 1);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("seasonal.error.catalogEmpty"));
|
||||
}
|
||||
|
||||
void formatVersionChecks_data()
|
||||
{
|
||||
QTest::addColumn<int>("format");
|
||||
QTest::addColumn<QString>("error");
|
||||
QTest::newRow("too new") << 2 << Loc::text("seasonal.error.formatTooNew").arg(2);
|
||||
QTest::newRow("unknown") << 0 << Loc::text("seasonal.error.formatUnknown").arg(0);
|
||||
}
|
||||
|
||||
void formatVersionChecks()
|
||||
{
|
||||
QFETCH(int, format);
|
||||
QFETCH(QString, error);
|
||||
QJsonObject catalog = Fixtures::readJson("seasonal/index.json");
|
||||
catalog["formatVersion"] = format;
|
||||
m_net.route(kIndex, FakeResponse::json(catalog));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, error);
|
||||
QCOMPARE(service.lastError(), error);
|
||||
}
|
||||
|
||||
void cacheIsBoundToBaseUrl()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
// Тот же адрес — свежий кеш, без сети.
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QCOMPARE(m_net.hits(kIndex), 1);
|
||||
}
|
||||
// Другой адрес — чужой кеш не подхватывается.
|
||||
SeasonalBuildService other;
|
||||
other.setBaseUrl(QUrl("https://mirror.example"));
|
||||
m_net.route("https://mirror.example/index.json", FakeResponse::error(503));
|
||||
CallResult r;
|
||||
other.ensureLoaded(r.callback());
|
||||
QVERIFY(!other.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("seasonal.error.catalogFetchFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void setBaseUrlResetsData()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(service.hasData());
|
||||
|
||||
QSignalSpy changed(&service, &SeasonalBuildService::buildsChanged);
|
||||
service.setBaseUrl(kBase); // тот же адрес — ничего не происходит
|
||||
QCOMPARE(changed.count(), 0);
|
||||
service.setBaseUrl(QUrl("https://mirror.example"));
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QVERIFY(!service.hasData());
|
||||
QVERIFY(!service.find("season-5").has_value());
|
||||
}
|
||||
|
||||
void offlineWithCacheKeepsBuilds()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
m_net.route(kIndex, FakeResponse::error(502));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback(), /*forceRefresh=*/true);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.builds().size(), 3);
|
||||
QCOMPARE(service.lastError(), r.warning);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestSeasonalBuildService)
|
||||
#include "tst_seasonalbuildservice.moc"
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "localization.h"
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QString kPack = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-5-r3.zip");
|
||||
}
|
||||
|
||||
class TestSeasonalPackDownloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_dir;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(SeasonalPackDownloader &d)
|
||||
: started(&d, &SeasonalPackDownloader::started)
|
||||
, finished(&d, &SeasonalPackDownloader::finished)
|
||||
, failed(&d, &SeasonalPackDownloader::failed)
|
||||
, canceled(&d, &SeasonalPackDownloader::canceled)
|
||||
{}
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 20000);
|
||||
}
|
||||
QSignalSpy started, finished, failed, canceled;
|
||||
};
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
m_dir = TestEnv::freshDir(QStringLiteral("packs"));
|
||||
}
|
||||
|
||||
void downloadsAndVerifies()
|
||||
{
|
||||
const QByteArray data(300 * 1024, 'p');
|
||||
m_net.route(kPack, FakeResponse::bytes(data));
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
const QString target = m_dir + "/sub/season-5.zip";
|
||||
downloader.download(QUrl(kPack), target, Fixtures::sha256(data).toUpper(), data.size(), "Season 5");
|
||||
QVERIFY(downloader.isRunning());
|
||||
QCOMPARE(downloader.label(), QStringLiteral("Season 5"));
|
||||
QCOMPARE(downloader.currentFile(), QStringLiteral("season-5.zip"));
|
||||
QCOMPARE(downloader.bytesTotal(), qint64(data.size()));
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.finished.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().constFirst().toString(), target);
|
||||
QCOMPARE(Fixtures::readFile(target), data);
|
||||
QVERIFY(!downloader.isRunning());
|
||||
QCOMPARE(downloader.fraction(), -1.0);
|
||||
}
|
||||
|
||||
void checksumMismatchLeavesNoFile()
|
||||
{
|
||||
m_net.route(kPack, FakeResponse::bytes("tampered"));
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
const QString target = m_dir + "/season.zip";
|
||||
downloader.download(QUrl(kPack), target, QString(64, u'a'), 8, "S");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("S"));
|
||||
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.checksumMismatch"));
|
||||
QVERIFY(!QFile::exists(target));
|
||||
}
|
||||
|
||||
void httpErrorAndTruncation()
|
||||
{
|
||||
SeasonalPackDownloader downloader;
|
||||
{
|
||||
m_net.route(kPack, FakeResponse::error(404));
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/a.zip", QString(64, u'a'), 0, "A");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.constFirst().at(1).toString().startsWith(
|
||||
Loc::text("seasonal.error.downloadFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
{
|
||||
FakeResponse cut = FakeResponse::bytes(QByteArray(4096, 'x'));
|
||||
cut.truncateTo = 100;
|
||||
m_net.route(kPack, cut);
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
|
||||
}
|
||||
}
|
||||
|
||||
void cancelInFlight()
|
||||
{
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
m_net.route(kPack, hang);
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/c.zip", QString(64, u'a'), 10, "C");
|
||||
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kPack), 1, 15000);
|
||||
downloader.cancel();
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("C"));
|
||||
QVERIFY(!QFile::exists(m_dir + "/c.zip"));
|
||||
QVERIFY(!downloader.isRunning());
|
||||
downloader.cancel(); // повторная отмена без загрузки — ничего не делает
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
}
|
||||
|
||||
void secondDownloadWhileBusyIsRejected()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::bytes("x");
|
||||
slow.delayMs = 500;
|
||||
m_net.route(kPack, slow);
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/d.zip", Fixtures::sha256("x"), 1, "D");
|
||||
downloader.download(QUrl(kPack), m_dir + "/e.zip", Fixtures::sha256("x"), 1, "E");
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("E"));
|
||||
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.downloadInProgress"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(run.finished.count(), 1, 15000);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestSeasonalPackDownloader)
|
||||
#include "tst_seasonalpackdownloader.moc"
|
||||
@@ -0,0 +1,383 @@
|
||||
#include "testenv.h"
|
||||
#include "fakemojang.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "gamelauncher.h"
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestVersionInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_game;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(VersionInstaller &installer)
|
||||
: started(&installer, &VersionInstaller::started)
|
||||
, finished(&installer, &VersionInstaller::finished)
|
||||
, failed(&installer, &VersionInstaller::failed)
|
||||
, canceled(&installer, &VersionInstaller::canceled)
|
||||
, progress(&installer, &VersionInstaller::progressChanged)
|
||||
{}
|
||||
bool waitEnd(int expectedEvents = 1)
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() >= expectedEvents; },
|
||||
30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constLast().at(1).toString(); }
|
||||
QSignalSpy started, finished, failed, canceled, progress;
|
||||
};
|
||||
|
||||
void verifyInstalled(const FakeMojang &mojang, const QString &id)
|
||||
{
|
||||
const auto expected = mojang.expectedFiles(id);
|
||||
QVERIFY(!expected.isEmpty());
|
||||
for (auto it = expected.cbegin(); it != expected.cend(); ++it) {
|
||||
const QString path = m_game + u'/' + it.key();
|
||||
QVERIFY2(QFile::exists(path), qPrintable("нет файла " + it.key()));
|
||||
QCOMPARE(Fixtures::readFile(path), it.value());
|
||||
}
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void installsModernVersionCompletely()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(installer.isRunning());
|
||||
QCOMPARE(installer.versionId(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(run.progress.count() > 0);
|
||||
QVERIFY(m_net.unmatched().isEmpty());
|
||||
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
// Результат пригоден для запуска: GameLauncher не видит недостающих файлов.
|
||||
QString error;
|
||||
const MinecraftVersion version = VersionLoader::load(m_game, "1.20.1", {}, &error);
|
||||
QVERIFY2(version.isValid(), qPrintable(error));
|
||||
LaunchOptions options;
|
||||
options.gameDir = m_game;
|
||||
QVERIFY(GameLauncher::missingFiles(options, version).isEmpty());
|
||||
}
|
||||
|
||||
void secondInstallSkipsExistingFiles()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
{
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
}
|
||||
const int firstRequests = m_net.requests().size();
|
||||
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
// Повторно скачивается только json версии; файлы нужного размера пропускаются.
|
||||
const int extra = m_net.requests().size() - firstRequests;
|
||||
QVERIFY2(extra <= 1, qPrintable(QString::number(extra)));
|
||||
}
|
||||
|
||||
void legacyAssetsAreCopiedToVirtual()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.5.2", .legacyAssets = true, .javaMajor = 8});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.5.2");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "1.5.2"); // включая assets/virtual/legacy/*
|
||||
}
|
||||
|
||||
void inheritedVersionPullsParentFirst()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.publish({.id = "fabric-loader-0.16.10-1.20.1", .inheritsFrom = "1.20.1"});
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "fabric-loader-0.16.10-1.20.1");
|
||||
QVERIFY(run.waitEnd(2));
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
// started приходит на каждую версию, которую очередь берёт «с нуля».
|
||||
QCOMPARE(run.started.constFirst().constFirst().toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QStringList finishedIds;
|
||||
for (const auto &call : run.finished)
|
||||
finishedIds << call.constFirst().toString();
|
||||
QCOMPARE(finishedIds, QStringList({"1.20.1", "fabric-loader-0.16.10-1.20.1"}));
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
verifyInstalled(mojang, "fabric-loader-0.16.10-1.20.1");
|
||||
}
|
||||
|
||||
void localJsonUsedWhenNotInManifest()
|
||||
{
|
||||
// Версия не из манифеста (например, профиль модлоадера), но json уже лежит локально.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "custom-1", .listed = false});
|
||||
Fixtures::write(m_game + "/versions/custom-1/custom-1.json", mojang.versionJson("custom-1"));
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "custom-1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "custom-1");
|
||||
QCOMPARE(m_net.hits(mojang.versionJsonUrl("custom-1")), 0);
|
||||
}
|
||||
|
||||
void unknownVersionFails()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "9.9.9");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.notInManifest").arg("9.9.9"));
|
||||
}
|
||||
|
||||
void invalidNamesRejected_data()
|
||||
{
|
||||
QTest::addColumn<QString>("id");
|
||||
QTest::newRow("traversal") << "../evil";
|
||||
QTest::newRow("slash") << "a/b";
|
||||
QTest::newRow("dot") << ".";
|
||||
QTest::newRow("colon") << "c:x";
|
||||
}
|
||||
|
||||
void invalidNamesRejected()
|
||||
{
|
||||
QFETCH(QString, id);
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, id);
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.invalidName").arg(id));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
|
||||
installer.install(m_game, " "); // пустой id молча игнорируется
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
}
|
||||
|
||||
void versionJsonChecksumMismatch()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
m_net.route(mojang.versionJsonUrl("1.20.1"), FakeResponse::json("{\"tampered\":true}"));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.descriptionChecksum").arg("1.20.1"));
|
||||
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
|
||||
}
|
||||
|
||||
void transientErrorIsRetried()
|
||||
{
|
||||
// Сбой не на последнем файле: пока ждём повтора, остальные ещё качаются.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
|
||||
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
|
||||
slowJar.delayMs = 1500;
|
||||
mojang.overrideFile(jar, slowJar);
|
||||
int attempts = 0;
|
||||
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
|
||||
return ++attempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes("brigadier");
|
||||
});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(attempts, 2);
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
}
|
||||
|
||||
void retryOfLastFileIsNotLost()
|
||||
{
|
||||
// Сбой на последнем файле: остальные уже скачаны, повтор стоит в таймере.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
int attempts = 0;
|
||||
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
|
||||
if (++attempts > 1)
|
||||
return FakeResponse::bytes("brigadier");
|
||||
FakeResponse late = FakeResponse::error(503);
|
||||
late.delayMs = 1000; // все прочие файлы успевают докачаться
|
||||
return late;
|
||||
});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
// retryOrFail() откладывает повтор на 400 мс, а handleReplyFinished() тут же
|
||||
// зовёт pump(): очередь и активные загрузки пусты — установка «завершается»
|
||||
// и падает на проверке файлов, повтор так и не происходит.
|
||||
QEXPECT_FAIL("", "повтор последнего файла теряется: pump() завершает установку раньше таймера", Abort);
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(attempts, 2);
|
||||
}
|
||||
|
||||
void checksumMismatchRetriesThenFails()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
|
||||
// client.jar держим долго, чтобы все повторы библиотеки шли при живой установке.
|
||||
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
|
||||
slowJar.delayMs = 4000;
|
||||
mojang.overrideFile(jar, slowJar);
|
||||
mojang.overrideFile(lib, FakeResponse::bytes("corrupted"));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failure().startsWith(Loc::text("common.error.downloadFailed")
|
||||
.arg("com.mojang:brigadier:1.1.8", Loc::text("common.error.checksumMismatch"))),
|
||||
qPrintable(run.failure()));
|
||||
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 3); // kMaxAttempts
|
||||
QVERIFY(!QFile::exists(m_game + u'/' + lib)); // битый файл не сохранён
|
||||
}
|
||||
|
||||
void permanentErrorFailsImmediately()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
mojang.overrideFile(lib, FakeResponse::error(404));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(!run.failed.isEmpty());
|
||||
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 1);
|
||||
}
|
||||
|
||||
void cancelStopsDownloads()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
mojang.overrideFile("versions/1.20.1/1.20.1.jar", hang);
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_net.hits(mojang.fileUrl("versions/1.20.1/1.20.1.jar")) == 1, 15000);
|
||||
installer.cancel();
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(run.finished.isEmpty());
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
|
||||
}
|
||||
|
||||
void queueRunsSequentiallyAndDeduplicates()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
installer.install(m_game, "1.19.4");
|
||||
installer.install(m_game, "1.19.4"); // дубль в очереди
|
||||
installer.install(m_game, "1.20.1"); // уже ставится
|
||||
QVERIFY(installer.isQueued("1.19.4"));
|
||||
QVERIFY(!installer.isQueued("1.20.1"));
|
||||
QVERIFY(run.waitEnd(2));
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(run.finished.count(), 2);
|
||||
QCOMPARE(run.started.count(), 2);
|
||||
verifyInstalled(mojang, "1.19.4");
|
||||
}
|
||||
|
||||
void offlineWithLocalJsonStillInstalls()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", mojang.versionJson("1.20.1"));
|
||||
m_net.route(FakeMojang::manifestUrl(), FakeResponse::error(503));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
}
|
||||
|
||||
void fractionReportsProgress()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
QCOMPARE(installer.fraction(), -1.0);
|
||||
Run run(installer);
|
||||
double maxFraction = -1;
|
||||
connect(&installer, &VersionInstaller::progressChanged, this, [&] {
|
||||
maxFraction = std::max(maxFraction, installer.fraction());
|
||||
QVERIFY(installer.fraction() <= 1.0);
|
||||
});
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
QVERIFY(maxFraction > 0.0);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestVersionInstaller)
|
||||
#include "tst_versioninstaller.moc"
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QString kManifest = QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
}
|
||||
|
||||
class TestVersionManifestService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
}
|
||||
|
||||
void loadsSortsAndFilters()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
QSignalSpy changed(&service, &VersionManifestService::versionsChanged);
|
||||
QSignalSpy refreshing(&service, &VersionManifestService::refreshingChanged);
|
||||
CallResult result;
|
||||
service.ensureLoaded(result.callback());
|
||||
QVERIFY(service.isRefreshing());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(result.calls, 1, 15000);
|
||||
QVERIFY(result.ok);
|
||||
QVERIFY(result.warning.isEmpty());
|
||||
QVERIFY(!service.isRefreshing());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QCOMPARE(refreshing.count(), 2);
|
||||
|
||||
// Новые первыми; без id и с http-ссылкой — выброшены.
|
||||
QStringList ids;
|
||||
for (const RemoteVersionEntry &entry : service.versions())
|
||||
ids << entry.id;
|
||||
QCOMPARE(ids, QStringList({"25w33a", "1.21.8", "1.20.1", "b1.7.3"}));
|
||||
|
||||
const auto entry = service.find("1.20.1");
|
||||
QVERIFY(entry.has_value());
|
||||
QCOMPARE(entry->type, QStringLiteral("release"));
|
||||
QCOMPARE(entry->sha1, QStringLiteral("aaaa"));
|
||||
QCOMPARE(entry->url, QUrl("https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json"));
|
||||
QCOMPARE(entry->releaseTime, QDateTime(QDate(2023, 6, 12), QTime(13, 25, 51), QTimeZone::UTC));
|
||||
QVERIFY(!service.find("insecure").has_value());
|
||||
QVERIFY(service.fetchedAt().isValid());
|
||||
|
||||
// Кеш на диске записан.
|
||||
const QJsonObject cache = Fixtures::readJsonFile(LauncherPaths::versionManifestFile());
|
||||
QCOMPARE(cache.value("versions").toArray().size(), 4);
|
||||
QVERIFY(!cache.value("fetchedAt").toString().isEmpty());
|
||||
}
|
||||
|
||||
void freshCacheAvoidsNetwork()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
{
|
||||
VersionManifestService first;
|
||||
CallResult r;
|
||||
first.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
|
||||
// Новый экземпляр (как после перезапуска лаунчера) берёт свежий кеш.
|
||||
VersionManifestService second;
|
||||
CallResult r;
|
||||
second.ensureLoaded(r.callback());
|
||||
QVERIFY(second.hasData()); // данные из кеша уже доступны синхронно
|
||||
QCOMPARE(r.calls, 0); // колбэк — только через цикл событий
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QVERIFY(r.ok);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
|
||||
// forceRefresh идёт в сеть даже при свежем кеше.
|
||||
CallResult forced;
|
||||
second.ensureLoaded(forced.callback(), true);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(forced.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits(kManifest), 2);
|
||||
}
|
||||
|
||||
void staleCacheIsRefreshed()
|
||||
{
|
||||
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
|
||||
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
|
||||
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
}
|
||||
|
||||
void networkErrorWithoutCacheFails()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::error(503));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("version.error.manifestFetchFailed").section(u'%', 0, 0)));
|
||||
QVERIFY(!service.hasData());
|
||||
}
|
||||
|
||||
void networkErrorWithCacheSucceedsWithWarning()
|
||||
{
|
||||
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
|
||||
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
|
||||
m_net.route(kManifest, FakeResponse::error(500));
|
||||
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok); // офлайн, но список есть
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.versions().size(), 4);
|
||||
}
|
||||
|
||||
void garbageResponseIsRejected()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json("{\"versions\": []}"));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("version.error.manifestUnparsable"));
|
||||
QVERIFY(!QFile::exists(LauncherPaths::versionManifestFile()));
|
||||
}
|
||||
|
||||
void concurrentCallersShareOneRequest()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json"));
|
||||
slow.delayMs = 300;
|
||||
m_net.route(kManifest, slow);
|
||||
VersionManifestService service;
|
||||
CallResult a, b, c;
|
||||
service.ensureLoaded(a.callback());
|
||||
service.ensureLoaded(b.callback());
|
||||
service.ensureLoaded(c.callback());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1 && c.calls == 1, 15000);
|
||||
QVERIFY(a.ok && b.ok && c.ok);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
}
|
||||
|
||||
void corruptCacheIsIgnored()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::versionManifestFile(), "\x00\x01garbage");
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(!service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(service.hasData());
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestVersionManifestService)
|
||||
#include "tst_versionmanifestservice.moc"
|
||||
Reference in new issue
Block a user