Files
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

284 lines
9.1 KiB
Rust

//! refresh 语义与 validate 分支:轮换、换号防线、404 二义、拥有权门禁。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, RefreshDecision, RefreshReason,
};
use unlucky_core::auth::Secret;
use crate::support::{AuthMock, MockReply};
const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
const OTHER_PROFILE_ID: &str = "11111111111111111111111111111111";
const CLIENT_ID: &str = "test-client-id";
fn auth_for(base: &str) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_secs(5),
})
}
/// 一个「已过期」的旧会话(`not_after` 早于当前时刻)。
fn expired_session() -> MicrosoftSession {
MicrosoftSession::new(
PROFILE_ID,
"Steve",
Secret::new("mc-access-token-1"),
Secret::new("live-refresh-token-1"),
3_600,
0,
None,
)
}
/// 一个「新鲜」的会话。
fn fresh_session() -> MicrosoftSession {
MicrosoftSession::new(
PROFILE_ID,
"Steve",
Secret::new("mc-access-token-1"),
Secret::new("live-refresh-token-1"),
86_400,
unlucky_core::auth::microsoft::now_ms(),
None,
)
}
/// 刷新链(Live refresh + 五步)。
fn refresh_routes(profile_id: &str, license: bool) -> Vec<(&'static str, Vec<MockReply>)> {
vec![
(
"/token",
vec![MockReply::json(json!({
"access_token": "live-access-token-2",
"refresh_token": "live-refresh-token-2"
}))],
),
(
"/xbl",
vec![MockReply::json(json!({
"Token": "xbl-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] }
}))],
),
(
"/xsts",
vec![MockReply::json(json!({
"Token": "xsts-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] }
}))],
),
(
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-2",
"expires_in": 86_400
}))],
),
(
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
),
(
"/entitlements/license",
vec![MockReply::json(if license {
json!({ "items": [{ "name": "game_minecraft" }] })
} else {
json!({ "items": [{ "name": "product_minecraft" }] })
})],
),
(
"/profile",
vec![MockReply::json(
json!({ "id": profile_id, "name": "Steve" }),
)],
),
]
}
#[test]
fn refresh_decision_table() {
let expired = expired_session();
assert_eq!(
expired.refresh_decision(unlucky_core::auth::microsoft::now_ms()),
RefreshDecision::Refresh(RefreshReason::Expired)
);
let fresh = fresh_session();
assert_eq!(
fresh.refresh_decision(unlucky_core::auth::microsoft::now_ms()),
RefreshDecision::Reuse
);
}
#[tokio::test]
async fn refresh_rotates_tokens_and_keeps_profile() {
let mock = AuthMock::start(refresh_routes(PROFILE_ID, true)).await;
let auth = auth_for(&mock.base());
let old = expired_session();
let (refreshed, reason) = auth.ensure_fresh(&old).await.expect("刷新成功");
assert_eq!(reason, Some(RefreshReason::Expired));
assert_eq!(refreshed.profile_id, PROFILE_ID);
// 轮换:access 与 refresh **都**换新(refresh 返回新 refresh_token)。
assert_eq!(refreshed.access_token.expose(), "mc-access-token-2");
assert_eq!(refreshed.refresh_token.expose(), "live-refresh-token-2");
assert_ne!(refreshed.access_token, old.access_token);
assert_ne!(refreshed.refresh_token, old.refresh_token);
assert!(refreshed.not_after_ms > old.not_after_ms);
// 刷新报文:拿旧 refresh_token 走 refresh_token grant。
let body = &mock.bodies_on("/token")[0];
assert!(body.contains("grant_type=refresh_token"), "{body}");
assert!(
body.contains("refresh_token=live-refresh-token-1"),
"{body}"
);
// 整链重跑:XBL 用的是**新的** Live access token。
let xbl = &mock.bodies_on("/xbl")[0];
assert!(xbl.contains("d=live-access-token-2"), "{xbl}");
mock.stop();
}
#[tokio::test]
async fn fresh_session_needs_no_network() {
let mock = AuthMock::start(vec![("/token", vec![MockReply::status(500)])]).await;
let auth = auth_for(&mock.base());
let (session, reason) = auth.ensure_fresh(&fresh_session()).await.expect("直接复用");
assert!(reason.is_none());
assert_eq!(session.access_token.expose(), "mc-access-token-1");
assert!(mock.requests().is_empty(), "不该打网络");
mock.stop();
}
#[tokio::test]
async fn refresh_that_changes_profile_is_rejected() {
let mock = AuthMock::start(refresh_routes(OTHER_PROFILE_ID, true)).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应拒绝");
assert_eq!(error.code(), "malformed");
assert!(error.message().contains("档案变了"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn revoked_refresh_token_requires_relogin() {
let mock = AuthMock::start(vec![(
"/token",
vec![MockReply::json_status(
400,
json!({
"error": "invalid_grant",
"error_description": "AADSTS70000: The grant is expired or revoked"
}),
)],
)])
.await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
assert!(error.is_credential_expired(), "{error:?}");
mock.stop();
}
#[tokio::test]
async fn validate_reports_token_state_without_swallowing_profile_errors() {
// 200 → 有效
let ok = AuthMock::start(vec![(
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
)])
.await;
assert!(auth_for(&ok.base())
.validate(&fresh_session())
.await
.expect("有效"));
ok.stop();
// 401 → token 失效(返回值,不是错误)
let unauthorized = AuthMock::start(vec![("/profile", vec![MockReply::status(401)])]).await;
assert!(!auth_for(&unauthorized.base())
.validate(&fresh_session())
.await
.expect("失效"));
unauthorized.stop();
// 404 + 没有 game_minecraft → 「没买游戏」是真实错误,不当 token 失效
let no_license = AuthMock::start(vec![
("/profile", vec![MockReply::status(404)]),
(
"/entitlements/license",
vec![MockReply::json(
json!({ "items": [{ "name": "product_minecraft" }] }),
)],
),
])
.await;
let error = auth_for(&no_license.base())
.validate(&fresh_session())
.await
.expect_err("应上抛");
assert_eq!(error.code(), "denied");
assert!(error.message().contains("没有购买"), "{}", error.message());
no_license.stop();
// 404 + 有 game_minecraft → 「买了没建档案」
let no_profile = AuthMock::start(vec![
("/profile", vec![MockReply::status(404)]),
(
"/entitlements/license",
vec![MockReply::json(
json!({ "items": [{ "name": "game_minecraft" }] }),
)],
),
])
.await;
let error = auth_for(&no_profile.base())
.validate(&fresh_session())
.await
.expect_err("应上抛");
assert!(
error.message().contains("还没有创建档案"),
"{}",
error.message()
);
no_profile.stop();
}
#[tokio::test]
async fn missing_entitlement_fails_the_chain_early() {
let mut routes = refresh_routes(PROFILE_ID, true);
routes[4].1 = vec![MockReply::status(404)];
let mock = AuthMock::start(routes).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
match error {
unlucky_core::auth::AuthError::Denied { code, .. } => assert_eq!(code, "no_entitlement"),
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn uhs_mismatch_between_xbl_and_xsts_is_malformed() {
let mut routes = refresh_routes(PROFILE_ID, true);
routes[2].1 = vec![MockReply::json(json!({
"Token": "xsts-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-diFFERENT" }] }
}))];
let mock = AuthMock::start(routes).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
assert_eq!(error.code(), "malformed");
assert!(error.message().contains("userhash"), "{}", error.message());
mock.stop();
}