#include "testenv.h" #include "fixtures.h" #include "nettest.h" #include "authservice.h" #include "localization.h" #include #include 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"