#include "testenv.h" #include "nettest.h" #include "authservice.h" #include "localization.h" #include "msaauthservice.h" #include #include #include 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("xerr"); QTest::addColumn("message"); QTest::addColumn("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("url"); QTest::addColumn("status"); QTest::addColumn("body"); QTest::addColumn("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"