262 lines
12 KiB
C++
262 lines
12 KiB
C++
#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"
|