Unlucky Launcher v0.0.1-alpha
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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
//! device-code 全链(打到进程内 mock 服务器):报文形状 + 轮询状态机 + 错误分流。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
DeviceCodePrompt, MicrosoftAuth, MicrosoftEndpoints, PollPolicy,
};
use unlucky_core::auth::AuthError;
use crate::support::{AuthMock, MockReply};
const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
const CLIENT_ID: &str = "test-client-id";
fn fast_poll() -> PollPolicy {
PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_secs(5),
}
}
fn device_code_reply() -> MockReply {
MockReply::json(json!({
"user_code": "ABCD-1234",
"device_code": "device-code-secret-value",
"verification_uri": "https://www.microsoft.com/link",
"expires_in": 900,
"interval": 0,
"message": "go to the url"
}))
}
fn chain_replies() -> Vec<(&'static str, Vec<MockReply>)> {
vec![
("/devicecode", vec![device_code_reply()]),
(
"/token",
vec![
MockReply::json(json!({ "error": "authorization_pending" })),
MockReply::json(json!({
"access_token": "live-access-token-value",
"refresh_token": "live-refresh-token-value"
})),
],
),
(
"/xbl",
vec![MockReply::json(json!({
"Token": "xbl-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
(
"/xsts",
vec![MockReply::json(json!({
"Token": "xsts-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
(
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-value",
"token_type": "Bearer",
"expires_in": 86_400
}))],
),
(
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
),
(
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
),
]
}
#[tokio::test]
async fn device_code_login_runs_the_whole_chain() {
let mock = AuthMock::start(chain_replies()).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let mut prompted: Option<DeviceCodePrompt> = None;
let session = auth
.device_code_login(|prompt| prompted = Some(prompt.clone()))
.await
.expect("登录成功");
// 1. 用户码/URL 在轮询前就交给宿主了(CLI 才能先打印再等)。
let prompt = prompted.expect("必须回调展示用户码");
assert_eq!(prompt.user_code, "ABCD-1234");
assert_eq!(prompt.verification_uri, "https://www.microsoft.com/link");
assert_eq!(prompt.expires_in, Duration::from_secs(900));
assert_eq!(prompt.interval, Duration::ZERO, "服务端 interval 原样收下");
let (line1, line2) = prompt.lines();
assert!(line1.contains("https://www.microsoft.com/link"));
assert!(line2.contains("ABCD-1234"));
// 2. 会话字段:MC token 落 access_token,Live refresh 落 refresh_token。
assert_eq!(session.profile_id, PROFILE_ID);
assert_eq!(session.profile_name, "Steve");
assert_eq!(session.access_token.expose(), "mc-access-token-value");
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
assert!(session.not_after_ms > 1_577_836_800_000);
assert!(session.has_profile_name());
// Debug 里不能出现任何 token。
let debug = format!("{session:?}");
assert!(!debug.contains("mc-access-token-value"), "{debug}");
assert!(!debug.contains("live-refresh-token-value"), "{debug}");
// 3. 报文形状(这几处抄错就会 400,必须逐条盯住)。
let device_bodies = mock.bodies_on("/devicecode");
assert_eq!(device_bodies.len(), 1);
assert!(device_bodies[0].contains("client_id=test-client-id"));
assert!(
device_bodies[0].contains("scope=XboxLive.signin+offline_access")
|| device_bodies[0].contains("scope=XboxLive.signin%20offline_access")
);
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 2, "pending 一次后成功");
for body in &token_bodies {
assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"));
assert!(body.contains("code=device-code-secret-value"));
assert!(!body.contains("{"), "表单体不该是 JSON:{body}");
}
let xbl = &mock.bodies_on("/xbl")[0];
// `d=` 前缀是 MSA 消费版票据标记(漏了就 400)。
assert!(
xbl.contains("\"RpsTicket\":\"d=live-access-token-value\""),
"{xbl}"
);
assert!(xbl.contains("user.auth.xboxlive.com"));
let xsts = &mock.bodies_on("/xsts")[0];
assert!(xsts.contains("rp://api.minecraftservices.com/"), "{xsts}");
assert!(xsts.contains("\"SandboxId\":\"RETAIL\""), "{xsts}");
assert!(xsts.contains("xbl-token-value"), "{xsts}");
let login = &mock.bodies_on("/login_with_xbox")[0];
assert!(
login.contains("XBL3.0 x=uhs-1;xsts-token-value"),
"identityToken 拼接错:{login}"
);
mock.stop();
}
#[tokio::test]
async fn slow_down_backs_off_instead_of_failing() {
let mut routes = chain_replies();
// 把 /token 换成 pending → slow_down → 成功。
routes[1].1 = vec![
MockReply::json(json!({ "error": "authorization_pending" })),
MockReply::json(json!({ "error": "slow_down" })),
MockReply::json(json!({
"access_token": "live-access-token-value",
"refresh_token": "live-refresh-token-value"
})),
];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let session = auth.device_code_login(|_| {}).await.expect("登录成功");
assert_eq!(session.profile_name, "Steve");
assert_eq!(mock.bodies_on("/token").len(), 3, "三次轮询");
mock.stop();
}
#[tokio::test]
async fn access_denied_is_reported_as_canceled() {
let mut routes = chain_replies();
routes[1].1 = vec![MockReply::json(json!({ "error": "access_denied" }))];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应取消");
assert_eq!(error, AuthError::Canceled);
assert_eq!(error.code(), "canceled");
mock.stop();
}
#[tokio::test]
async fn expired_device_code_is_a_timeout() {
let mut routes = chain_replies();
routes[1].1 = vec![MockReply::json(json!({ "error": "expired_token" }))];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应超时");
assert_eq!(error.code(), "timeout");
mock.stop();
}
#[tokio::test]
async fn polling_gives_up_after_the_wait_budget() {
let mut routes = chain_replies();
let pending = MockReply::json(json!({ "error": "authorization_pending" }));
routes[1].1 = vec![pending; 64];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_millis(20),
});
let error = auth.device_code_login(|_| {}).await.expect_err("应超时");
assert_eq!(error.code(), "timeout");
assert!(mock.bodies_on("/token").len() < 64, "超时后必须停止轮询");
mock.stop();
}
#[tokio::test]
async fn xsts_401_with_xerr_is_mapped() {
let mut routes = chain_replies();
routes[2].1 = vec![MockReply::json_status(
401,
json!({ "Identity": "0", "XErr": 2_148_916_233_u32, "Message": "no xbox account" }),
)];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应失败");
match error {
AuthError::Xbox { code } => {
assert_eq!(code, 2_148_916_233);
assert!(unlucky_core::auth::error::xbox_hint(code).contains("Xbox 档案"));
}
other => panic!("应是 Xbox 错误,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn device_code_endpoint_failure_is_denied() {
let mock = AuthMock::start(vec![(
"/devicecode",
vec![MockReply::json_status(
400,
json!({ "error": "invalid_client", "error_description": "bad client id" }),
)],
)])
.await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应失败");
match error {
AuthError::Denied { code, .. } => assert_eq!(code, "invalid_client"),
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}