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

@@ -0,0 +1,151 @@
//! M38:认证失败事件落盘(`.ULL/logs/auth.jsonl`)与凭据红线。
//!
//! 验收场景:`ull accounts add msa` 失败后,终端文案会滚走;日志区里要留下
//! 一行结构化记录(`code` / `detail_code` / `status` / `content_type` / `body_kind` /
//! 已脱敏摘要),且**不含任何 token**。便携数据根还没初始化时先补建 `logs/`。
use serde_json::json;
use unlucky_core::auth::{AuthError, AuthFailureEvent, AuthJournal, BodyKind};
use crate::support::{
assert_no_credentials, auth_for, expired_session, full_routes, routes_until_login, temp_dir,
AuthMock, MockReply, HTML_BLOCK_PAGE,
};
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
#[tokio::test]
async fn body_digest_never_carries_credentials() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json_status(
403,
json!({ "error": "DENIED", "echo": "xsts-token-value", "jwt": JWT }),
)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
let message = error.message();
assert_no_credentials(&message);
// 长不透明串(JWT)必须打码,别把 token 回显抄进摘要。
assert!(!message.contains("eyJhbGci"), "JWT 必须脱敏:{message}");
assert!(message.contains("redacted"), "{message}");
// 事件层同样不落凭据。
let event = AuthFailureEvent::of("device_code_login", &error);
let json = event.to_json().to_string();
assert_no_credentials(&json);
assert!(!json.contains("eyJhbGci"), "{json}");
assert!(json.contains("login_with_xbox_403"), "{json}");
mock.stop();
}
#[tokio::test]
async fn failure_journal_initializes_logs_and_records_the_403() {
let root = temp_dir("diag-journal");
let logs = root.join(".ULL").join("logs");
let journal = AuthJournal::in_logs_dir(&logs);
assert!(!logs.exists(), "模拟未初始化的便携数据根");
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::html(403, HTML_BLOCK_PAGE)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.with_journal(Some(journal.clone()))
.device_code_login(|_| {})
.await
.expect_err("应失败");
let text = std::fs::read_to_string(journal.path()).expect("失败日志必须落盘");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 1, "一次失败一条记录:{text}");
let record: serde_json::Value = serde_json::from_str(lines[0]).expect("合法 JSONL");
assert_eq!(record["event"], "auth_failure");
assert_eq!(record["level"], "error");
assert_eq!(record["scope"], "device_code_login");
assert_eq!(record["step"], "login_with_xbox");
assert_eq!(record["detail_code"], "login_with_xbox_non_json");
assert_eq!(record["status"], 403);
assert_eq!(record["body_kind"], "html");
assert_eq!(record["transient"], true);
assert_eq!(
record["message"],
error.message(),
"事件里的文案要与返回的错误一致"
);
assert_no_credentials(&text);
mock.stop();
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn journal_event_shape_covers_non_chain_errors() {
let event = AuthFailureEvent::of("refresh", &AuthError::Canceled);
assert_eq!(event.detail_code, "canceled");
assert_eq!(event.step, None);
assert_eq!(event.status, None);
assert_eq!(event.body_kind, None);
assert!(!event.transient);
assert_eq!(event.to_json()["scope"], "refresh");
let detail = unlucky_core::auth::ResponseDetail {
step: "profile".to_string(),
what: "拉取 Minecraft 档案被拒".to_string(),
status: 401,
content_type: Some("application/json".to_string()),
kind: BodyKind::Json,
server_message: Some("Unauthorized".to_string()),
digest: "{\"error\":\"Unauthorized\"}".to_string(),
};
let chain = AuthFailureEvent::of("device_code_login", &AuthError::Response(Box::new(detail)));
assert_eq!(chain.step.as_deref(), Some("profile"));
assert_eq!(chain.detail_code, "profile_401");
assert_eq!(chain.status, Some(401));
assert!(!chain.transient, "JSON 拒绝不是可重试的网络问题");
}
#[tokio::test]
async fn successful_login_writes_no_journal_and_refresh_failures_do() {
let root = temp_dir("diag-journal-ok");
let journal = AuthJournal::in_logs_dir(root.join("logs"));
// 成功:不落盘。
let mock = AuthMock::start(full_routes()).await;
auth_for(&mock.base())
.with_journal(Some(journal.clone()))
.device_code_login(|_| {})
.await
.expect("应登录成功");
assert!(!journal.path().exists(), "成功路径不写失败日志");
mock.stop();
// 刷新失败:落一条(凭据被撤销 = 要人重新登录)。
let failing = AuthMock::start(vec![(
"/token",
vec![MockReply::json_status(
400,
json!({ "error": "invalid_grant", "error_description": "AADSTS70000" }),
)],
)])
.await;
let error = auth_for(&failing.base())
.with_journal(Some(journal.clone()))
.refresh(&expired_session())
.await
.expect_err("应失败");
assert!(error.is_credential_expired());
let text = std::fs::read_to_string(journal.path()).expect("刷新失败也要落盘");
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("refresh"), "{text}");
assert!(text.contains("credential_expired"), "{text}");
failing.stop();
let _ = std::fs::remove_dir_all(&root);
}
+307
View File
@@ -0,0 +1,307 @@
//! Yggdrasil(authlib-injector)四动作的报文级对接:登录 / 刷新 / 校验 / 注销。
use serde_json::json;
use unlucky_core::auth::authlib::{
AuthlibAuth, AuthlibEndpoints, AuthlibProfile, AuthlibServerMeta, AuthlibSession,
};
use unlucky_core::auth::{AuthError, Secret};
use crate::support::{AuthMock, MockReply};
const API_ROOT: &str = "https://example.invalid/api/yggdrasil/";
const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef";
fn paths() -> (&'static str, &'static str, &'static str, &'static str) {
(
"/api/yggdrasil/authserver/authenticate",
"/api/yggdrasil/authserver/refresh",
"/api/yggdrasil/authserver/validate",
"/api/yggdrasil/authserver/invalidate",
)
}
/// 把 API 根改指到 mock(`base` 代替 https://example.invalid)。
fn mock_root(mock: &AuthMock) -> String {
format!("{}/api/yggdrasil", mock.base())
}
fn session() -> AuthlibSession {
AuthlibSession {
access_token: Secret::new("access-token-value"),
client_token: Secret::new("client-token-value"),
selected_profile: Some(AuthlibProfile {
id: PROFILE_ID.to_string(),
name: "Steve".to_string(),
}),
}
}
#[tokio::test]
async fn authenticate_sends_the_canonical_body() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json(json!({
"accessToken": "access-token-value",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let login = auth
.authenticate(
&mock_root(&mock),
"steve@example.com",
&Secret::new("hunter2-password"),
&Secret::new("client-token-value"),
)
.await
.expect("登录成功");
assert_eq!(login.access_token.expose(), "access-token-value");
assert_eq!(login.client_token.expose(), "client-token-value");
assert!(login.has_profile());
assert_eq!(
login.selected_profile.as_ref().map(|p| p.name.as_str()),
Some("Steve")
);
let body = &mock.bodies_on(authenticate)[0];
assert!(
body.contains("\"agent\":{\"name\":\"Minecraft\",\"version\":1}"),
"{body}"
);
assert!(body.contains("\"requestUser\":true"), "{body}");
assert!(
body.contains("\"username\":\"steve@example.com\""),
"{body}"
);
assert!(
body.contains("\"clientToken\":\"client-token-value\""),
"{body}"
);
// 密码确实发了(否则登录不了),但会话 Debug 里看不到它。
assert!(body.contains("hunter2-password"));
assert!(!format!("{login:?}").contains("hunter2-password"));
mock.stop();
}
#[tokio::test]
async fn wrong_password_is_denied_with_server_message() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json_status(
403,
json!({
"error": "ForbiddenOperationException",
"errorMessage": "Invalid credentials. Invalid username or password."
}),
)],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.authenticate(
&mock_root(&mock),
"steve",
&Secret::new("wrong"),
&Secret::new("client-token-value"),
)
.await
.expect_err("应失败");
match error {
AuthError::Denied { code, description } => {
assert_eq!(code, "ForbiddenOperationException");
assert!(description.contains("Invalid credentials"));
}
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn server_must_echo_the_client_token() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json(json!({
"accessToken": "access-token-value",
"clientToken": "someone-elses-token",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.authenticate(
&mock_root(&mock),
"steve",
&Secret::new("pw"),
&Secret::new("client-token-value"),
)
.await
.expect_err("应拒绝");
assert_eq!(error.code(), "malformed");
assert!(
error.message().contains("clientToken"),
"{}",
error.message()
);
mock.stop();
}
#[tokio::test]
async fn refresh_can_select_a_character() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json(json!({
"accessToken": "access-token-2",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Alex" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let wanted = AuthlibProfile {
id: PROFILE_ID.to_string(),
name: "Alex".to_string(),
};
let refreshed = auth
.refresh(&mock_root(&mock), &session(), Some(&wanted))
.await
.expect("刷新成功");
assert_eq!(refreshed.access_token.expose(), "access-token-2");
assert_eq!(refreshed.selected_profile.expect("角色").name, "Alex");
// 新的 refresh_token 不存在于 Yggdrasil:clientToken 保持不变。
assert_eq!(refreshed.client_token.expose(), "client-token-value");
let body = &mock.bodies_on(refresh)[0];
assert!(body.contains("\"selectedProfile\":{\"id\":\"0123456789abcdef0123456789abcdef\""));
assert!(body.contains("\"accessToken\":\"access-token-value\""));
mock.stop();
}
#[tokio::test]
async fn refresh_that_did_not_switch_character_is_malformed() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json(json!({
"accessToken": "access-token-2",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let wanted = AuthlibProfile {
id: "ffffffffffffffffffffffffffffffff".to_string(),
name: "Ghost".to_string(),
};
let error = auth
.refresh(&mock_root(&mock), &session(), Some(&wanted))
.await
.expect_err("应失败");
assert_eq!(error.code(), "malformed");
mock.stop();
}
#[tokio::test]
async fn forbidden_on_refresh_means_credential_expired() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }),
)],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.refresh(&mock_root(&mock), &session(), None)
.await
.expect_err("应失败");
assert!(error.is_credential_expired(), "{error:?}");
mock.stop();
}
#[tokio::test]
async fn validate_distinguishes_invalid_token_from_server_trouble() {
let (_, _, validate, _) = paths();
// 空体 200 → 有效
let ok = AuthMock::start(vec![(validate, vec![MockReply::status(200)])]).await;
let auth = AuthlibAuth::new().expect("构造");
assert!(auth
.validate(&mock_root(&ok), &session())
.await
.expect("有效"));
ok.stop();
// 403 ForbiddenOperationException → 无效(返回值 false,不抛错)
let invalid = AuthMock::start(vec![(
validate,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }),
)],
)])
.await;
assert!(!auth
.validate(&mock_root(&invalid), &session())
.await
.expect("无效"));
invalid.stop();
// 500 → 服务器问题,必须抛错(调用方才会重试而不是刷新)
let broken = AuthMock::start(vec![(validate, vec![MockReply::status(500)])]).await;
let error = auth
.validate(&mock_root(&broken), &session())
.await
.expect_err("应失败");
assert_eq!(error.code(), "network");
broken.stop();
}
#[tokio::test]
async fn invalidate_tolerates_already_invalid_tokens() {
let (_, _, _, invalidate) = paths();
let auth = AuthlibAuth::new().expect("构造");
let ok = AuthMock::start(vec![(invalidate, vec![MockReply::status(200)])]).await;
auth.invalidate(&mock_root(&ok), &session())
.await
.expect("注销成功");
ok.stop();
let gone = AuthMock::start(vec![(
invalidate,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException" }),
)],
)])
.await;
auth.invalidate(&mock_root(&gone), &session())
.await
.expect("已失效也算成功");
gone.stop();
}
#[test]
fn endpoints_and_metadata_stay_in_sync_with_the_protocol() {
let endpoints = AuthlibEndpoints::from_api_root(API_ROOT).expect("端点");
assert_eq!(
endpoints.authenticate,
format!("{API_ROOT}authserver/authenticate")
);
let meta = AuthlibServerMeta::from_value(&json!({
"meta": { "serverName": "Example", "feature.non_email_login": true },
"skinDomains": ["example.invalid"]
}));
assert_eq!(meta.name.as_deref(), Some("Example"));
assert!(meta.non_email_login);
assert_eq!(meta.skin_domains, vec!["example.invalid".to_string()]);
}
+19
View File
@@ -0,0 +1,19 @@
//! `core::auth` 的集成测试入口。
//!
//! 每个子模块对应 mission 的一项验收:
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
mod support;
mod auth_failure_journal;
mod authlib_api;
mod microsoft_diagnostics;
mod microsoft_flow;
mod microsoft_refresh;
mod offline_uuid;
mod store_separation;
@@ -0,0 +1,231 @@
//! M38:非 2xx 应答正文诊断(`AuthError::Response`)。
//!
//! 覆盖 MSA 真机验收里分不开的那几类:
//! - **JSON 403**:真实服务拒绝(服务端文案直出,引导查许可/区域);
//! - **HTML 403**:网关/风控拦截页(引导查网络/代理,独立细分码 `*_non_json`);
//! - **空体 403**:同样按「不确定」分流(`*_empty_body`);
//! - 4xx / 5xx 各一,外加 XErr 优先级与 JSON 码的向后兼容。
//!
//! 失败日志(JSONL)与凭据红线在同一目录的 `auth_failure_journal.rs`。
use serde_json::json;
use unlucky_core::auth::{AuthError, BodyKind};
use crate::support::{
assert_no_credentials, auth_for, full_routes, response_of, routes_until_login, AuthMock,
MockReply, HTML_BLOCK_PAGE,
};
#[tokio::test]
async fn json_403_surfaces_server_message_and_points_at_license() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json_status(
403,
json!({
"path": "/authentication/login_with_xbox",
"error": "UNAUTHORIZED",
"errorMessage": "You do not own Minecraft Java Edition"
}),
)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.code(), "denied", "粗分流层保持 M31 的 denied");
assert_eq!(error.detail_code(), "login_with_xbox_403");
assert!(!error.is_transient(), "服务端明确拒绝,重试无意义");
let detail = response_of(&error);
assert_eq!(detail.status, 403);
assert_eq!(detail.kind, BodyKind::Json);
assert_eq!(detail.content_type.as_deref(), Some("application/json"));
let message = error.message();
// 服务端文案直出(验收要看到真实原因)。
assert!(message.contains("You do not own"), "{message}");
assert!(message.contains("UNAUTHORIZED"), "{message}");
assert!(message.contains("许可"), "应引导查许可:{message}");
assert!(
!message.contains("代理"),
"JSON 拒绝不该往网络引:{message}"
);
assert!(message.contains("响应摘要"), "{message}");
assert_no_credentials(&message);
mock.stop();
}
#[tokio::test]
async fn html_403_is_routed_to_network_and_proxy() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::html(403, HTML_BLOCK_PAGE)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.code(), "denied");
assert_eq!(
error.detail_code(),
"login_with_xbox_non_json",
"拦截页要有独立细分码"
);
assert!(error.is_transient(), "拦截页可以换代理重试");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Html);
assert!(detail.is_interception());
assert_eq!(detail.status, 403);
assert!(
detail
.content_type
.as_deref()
.is_some_and(|value| value.contains("text/html")),
"{:?}",
detail.content_type
);
let message = error.message();
assert!(message.contains("响应非 JSON"), "{message}");
assert!(message.contains("网络"), "{message}");
assert!(message.contains("代理"), "{message}");
assert!(
message.contains("blocked by gateway"),
"摘要要留下证据:{message}"
);
assert!(
!message.contains("许可"),
"拦截页不该把用户引向许可:{message}"
);
assert_no_credentials(&message);
mock.stop();
}
#[tokio::test]
async fn empty_403_reports_the_empty_body() {
let mut routes = routes_until_login();
routes.push(("/login_with_xbox", vec![MockReply::status(403)]));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "login_with_xbox_empty_body");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Empty);
assert!(detail.digest.is_empty());
assert!(error.message().contains("为空"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn profile_4xx_keeps_the_step_status_code() {
let mut routes = full_routes();
routes[6].1 = vec![MockReply::json_status(
401,
json!({ "path": "/minecraft/profile", "error": "Unauthorized" }),
)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "profile_401");
assert!(error.message().contains("档案"), "{}", error.message());
assert_no_credentials(&error.message());
mock.stop();
}
#[tokio::test]
async fn entitlements_5xx_text_is_an_interception_not_a_license_verdict() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::text(503, "Service Unavailable")];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 关键:网关页不能报成「你没买游戏」(M31 的 no_entitlement 只留给 JSON/空体)。
assert_eq!(error.detail_code(), "entitlements_non_json");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Text);
assert_eq!(detail.status, 503);
assert!(
error.message().contains("Service Unavailable"),
"{}",
error.message()
);
mock.stop();
}
#[tokio::test]
async fn entitlements_404_with_empty_body_still_reports_no_entitlement() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::status(404)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 向后兼容:M31 的稳定码 `no_entitlement` 不动。
match error {
AuthError::Denied { code, description } => {
assert_eq!(code, "no_entitlement");
assert!(description.contains("404"), "{description}");
}
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn entitlements_403_with_empty_body_is_not_a_license_verdict() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::status(403)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 403 且没正文:说不清是许可还是拦截,按「不确定」报,不下「没买游戏」的结论。
assert_eq!(error.detail_code(), "entitlements_empty_body");
mock.stop();
}
#[tokio::test]
async fn xsts_401_without_xerr_still_reports_body_context() {
let mut routes = routes_until_login();
routes[3].1 = vec![MockReply::html(401, HTML_BLOCK_PAGE)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "xsts_non_json");
assert!(error.message().contains("XSTS"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn xerr_still_wins_over_the_body_diagnostic() {
let mut routes = routes_until_login();
routes[3].1 = vec![MockReply::json_status(
401,
json!({ "XErr": 2_148_916_235_u32, "Message": "country unavailable" }),
)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
match error {
AuthError::Xbox { code } => assert_eq!(code, 2_148_916_235),
other => panic!("XErr 判定必须优先,实际 {other:?}"),
}
mock.stop();
}
+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();
}
+283
View File
@@ -0,0 +1,283 @@
//! 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();
}
+105
View File
@@ -0,0 +1,105 @@
//! 离线 UUID 与 **JDK 对拍**(这就是「HMCL 兼容」的定义)。
//!
//! 期望值不是我们算出来再抄回来的,而是用 JDK 25 跑
//! `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8))`
//! 生成的(驱动源码见下方注释里的等价 Java)。HMCL 的
//! `OfflineAccountFactory.getUUIDFromUserName` 就是这一行,因此对拍 JDK 即对拍 HMCL。
//!
//! ```java
//! UUID id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
//! System.out.println(id.toString().replace("-", ""));
//! ```
use unlucky_core::auth::offline::{offline_login, offline_meta, offline_uuid, offline_uuid_dashed};
use unlucky_core::auth::AccountKind;
/// `(名字, JDK 输出 32 位无连字符)`。
const JDK_VECTORS: [(&str, &str); 7] = [
("Notch", "b50ad385829d3141a2167e7d7539ba7f"),
("jeb_", "a762f5604fce3236812ab80efff0b62b"),
("Player", "a01e3843e5213998958af459800e4d11"),
("Steve", "5627dd98e6be3c21b8a8e92344183641"),
// 非 ASCII:UTF-8 字节参与 MD5(中文名离线服常见)。
("小明", "e1fa999fd9e93419bc012f51157a00e5"),
("name with space", "46c02d33ff53313b813aa95508518305"),
("AskaEth", "5c5918fb61303d6eb8d8eb2d1da6e439"),
];
#[test]
fn matches_jdk_name_uuid_from_bytes() {
for (name, expected) in JDK_VECTORS {
assert_eq!(
offline_uuid(name),
expected,
"离线 UUID 与 JDK/HMCL 不一致:{name}"
);
}
}
#[test]
fn dashed_form_is_the_same_uuid() {
for (name, expected) in JDK_VECTORS {
let dashed = offline_uuid_dashed(name);
assert_eq!(dashed.replace('-', ""), expected, "{name} 的连字符形式");
assert_eq!(dashed.len(), 36);
assert_eq!(dashed.matches('-').count(), 4);
}
}
#[test]
fn version_is_3_and_variant_is_ietf() {
for (name, raw) in JDK_VECTORS {
let version_nibble = &raw[12..13];
assert_eq!(version_nibble, "3", "{name} 必须是 v3");
let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位");
assert!((0x8..=0xb).contains(&variant), "{name} 变体必须是 10xx");
}
}
#[test]
fn uuid_depends_on_exact_bytes_not_case() {
// 大小写不同 → 不同身份(服务端就是按字节算的,别做「友好」归一化)。
assert_ne!(offline_uuid("Steve"), offline_uuid("steve"));
// 前缀也参与哈希:直接哈希名字会得到另一个 UUID。
assert_ne!(offline_uuid("Notch"), offline_uuid("OfflinePlayer:Notch"));
}
#[test]
fn offline_login_matches_hmcl_behaviour() {
let meta = offline_meta("小明", 1).expect("建元数据");
assert_eq!(meta.kind, AccountKind::Offline);
assert_eq!(meta.profile_id, "e1fa999fd9e93419bc012f51157a00e5");
assert_eq!(meta.profile_name, "小明");
let placeholders = offline_login(&meta).expect("占位符");
// uuid 必须是**无连字符** 32 位(HMCL 的 UUIDs.toCompactString)。
assert_eq!(placeholders.uuid, meta.profile_id);
assert_eq!(placeholders.uuid.len(), 32);
assert!(!placeholders.uuid.contains('-'));
// accessToken = 随机 32 位十六进制(每次不同、非空:游戏不接受空 token)。
assert_eq!(placeholders.access_token.len(), 32);
assert!(placeholders
.access_token
.chars()
.all(|ch| ch.is_ascii_hexdigit()));
assert_ne!(
placeholders.access_token,
offline_login(&meta).expect("再来一次").access_token
);
// userType=msa、userProperties={}(承 OfflineAccount.logInWithoutSkin)。
assert_eq!(placeholders.user_type, "msa");
assert_eq!(placeholders.user_properties, "{}");
}
#[test]
fn names_are_validated_before_hashing() {
assert!(offline_meta(" ", 1).is_err());
assert!(offline_meta("0123456789abcdefg", 1).is_err());
// 16 字符刚好合法。
assert!(offline_meta("0123456789abcdef", 1).is_ok());
// 首尾空白被去掉后哈希(避免「名字带空格」算出两套 UUID)。
assert_eq!(
offline_meta(" Steve ", 1).expect("合法").profile_id,
offline_uuid("Steve")
);
}
+240
View File
@@ -0,0 +1,240 @@
//! 存储契约:**分文件 / 原子写 / 写序 / 容错 / 孤儿清理**。
//!
//! 这些是「凭据红线」的落盘侧证据:元数据文件里永远搜不到 token(可安全备份/分享),
//! 凭据文件只在需要发请求时才读,且写坏了也不会让账户列表整体打不开。
use std::path::Path;
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::MicrosoftSession;
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, Secret};
use crate::support::temp_dir;
const MC_TOKEN: &str = "mc-access-token-value";
const LIVE_REFRESH: &str = "live-refresh-token-value";
fn microsoft_entry(created_at_ms: u64) -> (AccountMeta, Credentials) {
let meta = AccountMeta::new(
AccountKind::Microsoft,
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
created_at_ms,
);
let session = MicrosoftSession::new(
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
Secret::new(MC_TOKEN),
Secret::new(LIVE_REFRESH),
86_400,
1_000,
None,
);
(meta, Credentials::Microsoft(session))
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("读文件")
}
fn tmp_leftovers(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.expect("列目录")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".tmp"))
.collect()
}
#[test]
fn metadata_and_credentials_live_in_separate_files() {
let dir = temp_dir("sep");
let mut store = AccountStore::open(&dir);
let (meta, credentials) = microsoft_entry(1);
store.insert(meta, credentials).expect("插入");
store.save().expect("落盘");
let accounts_text = read(&store.paths().accounts_file());
let credentials_text = read(&store.paths().credentials_file());
// 元数据:有身份、有轨道,但**没有任何 token**。
assert!(accounts_text.contains("0f8fad5bd9cb469fa16570867728950e"));
assert!(accounts_text.contains("\"kind\": \"microsoft\""));
assert!(!accounts_text.contains(MC_TOKEN), "{accounts_text}");
assert!(!accounts_text.contains(LIVE_REFRESH), "{accounts_text}");
// 凭据:有 token、有保护级别标记,但**没有**可公开的展示字段。
assert!(credentials_text.contains(MC_TOKEN));
assert!(credentials_text.contains(LIVE_REFRESH));
assert!(credentials_text.contains("\"protection\": \"plain-v1\""));
// 凭据文件里没有元数据那份「账户列表」结构(两个文件的 schema 各管各的)。
assert!(
!credentials_text.contains("\"accounts\""),
"{credentials_text}"
);
// 原子写不留 tmp 垃圾。
assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_keeps_credentials_intact() {
let dir = temp_dir("roundtrip");
let mut store = AccountStore::open(&dir);
let (meta, credentials) = microsoft_entry(1);
let id = meta.id.clone();
store.insert(meta, credentials.clone()).expect("插入");
store.save().expect("落盘");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1);
assert!(reopened.warnings().is_empty(), "{:?}", reopened.warnings());
assert_eq!(reopened.credentials(&id), Some(&credentials));
let placeholders = match reopened.credentials(&id).expect("凭据") {
Credentials::Microsoft(session) => session.to_placeholders().expect("占位符"),
other => panic!("应是微软凭据,实际 {other:?}"),
};
assert_eq!(placeholders.player_name, "Steve");
assert_eq!(placeholders.access_token, MC_TOKEN);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn credentials_are_written_before_metadata() {
let dir = temp_dir("order");
let mut store = AccountStore::open(&dir);
store
.insert(offline_meta("First", 1).expect("甲"), Credentials::Offline)
.expect("插入甲");
store.save().expect("首次落盘");
// 把凭据文件的位置换成目录 → 凭据写必定失败。
let credentials_path = store.paths().credentials_file();
std::fs::remove_file(&credentials_path).expect("删凭据文件");
std::fs::create_dir_all(&credentials_path).expect("建同名目录");
store
.insert(offline_meta("Second", 2).expect("乙"), Credentials::Offline)
.expect("插入乙");
let error = store.save().expect_err("凭据写不进去时 save 必须失败");
assert_eq!(error.code(), "store");
// 关键断言:元数据没有被先写(写序正确才有的结果)。
let accounts_text = read(&store.paths().accounts_file());
assert!(!accounts_text.contains("Second"), "{accounts_text}");
assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_record_is_skipped_not_fatal() {
let dir = temp_dir("corrupt");
let store = AccountStore::open(&dir);
let good = offline_meta("Good", 1).expect("合法元数据");
let payload = serde_json::json!({
"schemaVersion": 1,
"accounts": [
{ "id": good.id.as_str(), "kind": "offline",
"profileId": good.profile_id, "profileName": "Good", "createdAtMs": 1 },
{ "id": "account:zzzz", "kind": "offline" },
{ "id": "account:0123456789abcdef0123456789abcdef", "kind": "unknown-track" }
]
});
std::fs::write(
store.paths().accounts_file(),
serde_json::to_string_pretty(&payload).expect("序列化"),
)
.expect("写元数据");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1, "坏记录必须被跳过");
assert_eq!(reopened.accounts()[0].display_name(), "Good");
assert_eq!(reopened.warnings().len(), 2, "{:?}", reopened.warnings());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn broken_metadata_file_still_allows_empty_store() {
let dir = temp_dir("broken");
let store = AccountStore::open(&dir);
std::fs::write(store.paths().accounts_file(), "{ this is not json").expect("写坏文件");
let reopened = AccountStore::open(&dir);
assert!(reopened.is_empty());
assert_eq!(reopened.warnings().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn orphan_credentials_are_ignored_and_cleaned_on_save() {
let dir = temp_dir("orphan");
let store = AccountStore::open(&dir);
let orphan_id = "account:00000000000000000000000000000000";
let payload = serde_json::json!({
"schemaVersion": 1,
"protection": "plain-v1",
"entries": [{ "accountId": orphan_id, "data": { "kind": "offline" } }]
});
std::fs::write(
store.paths().credentials_file(),
serde_json::to_string_pretty(&payload).expect("序列化"),
)
.expect("写凭据");
let reopened = AccountStore::open(&dir);
assert!(reopened
.warnings()
.iter()
.any(|line| line.contains("没有对应账户")));
let unknown = unlucky_core::auth::AccountId::parse(orphan_id).expect("id");
assert!(reopened.credentials(&unknown).is_none());
reopened.save().expect("落盘");
let text = read(&reopened.paths().credentials_file());
assert!(!text.contains(orphan_id), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_protection_level_is_reported() {
let dir = temp_dir("protection");
let store = AccountStore::open(&dir);
std::fs::write(
store.paths().credentials_file(),
serde_json::to_string_pretty(&serde_json::json!({
"schemaVersion": 1,
"protection": "hmcl-obfuscated-v1",
"entries": []
}))
.expect("序列化"),
)
.expect("写凭据");
let reopened = AccountStore::open(&dir);
assert!(reopened
.warnings()
.iter()
.any(|line| line.contains("保护级别")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_drops_credential_entry_on_disk() {
let dir = temp_dir("remove");
let mut store = AccountStore::open(&dir);
store
.insert(offline_meta("Keep", 1).expect("甲"), Credentials::Offline)
.expect("甲");
let (meta, credentials) = microsoft_entry(2);
let id = meta.id.clone();
store.insert(meta, credentials).expect("乙");
store.save().expect("落盘");
store.remove("Steve").expect("删乙");
store.save().expect("再落盘");
let text = read(&store.paths().credentials_file());
assert!(!text.contains(id.as_str()), "{text}");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1);
assert_eq!(reopened.accounts()[0].display_name(), "Keep");
let _ = std::fs::remove_dir_all(&dir);
}
+136
View File
@@ -0,0 +1,136 @@
//! 微软轨的共用夹具:`MicrosoftAuth` 构造、五步链路由表、诊断断言助手。
//!
//! 从 `support/mod.rs` 拆出来(M38):mock 服务器本体与「业务路由/断言」各自长大,
//! 分文件后两边都不顶 400 行。路由表默认用 `[0]/[3]/[5]/[6]` 下标对应
//! `xsts` / `login_with_xbox` / `entitlements/mcstore` / `profile`,用例按需替换。
use serde_json::json;
use unlucky_core::auth::microsoft::{
MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy,
};
use unlucky_core::auth::{AuthError, ResponseDetail, Secret};
use super::MockReply;
/// 测试用档案 UUID(32 位无连字符)。
pub const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
/// 测试用 client id。
pub const CLIENT_ID: &str = "test-client-id";
/// 网关/风控拦截页(HTML 403 的典型正文)。
pub const HTML_BLOCK_PAGE: &str = "<!DOCTYPE html><html><head><title>Service unavailable</title></head><body>blocked by gateway</body></html>";
/// 小间隔轮询策略(设备码轮询在测试里要秒级跑完)。
pub fn fast_poll() -> PollPolicy {
PollPolicy {
min_interval: std::time::Duration::from_millis(1),
slow_down_step: std::time::Duration::from_millis(1),
max_wait: std::time::Duration::from_secs(5),
}
}
/// 一台整表指向 mock 的 `MicrosoftAuth`。
pub fn auth_for(base: &str) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(fast_poll())
}
/// 一路通到 `login_with_xbox` 的路由表(后续步骤留给用例替换)。
pub fn routes_until_login() -> Vec<(&'static str, Vec<MockReply>)> {
vec![
(
"/devicecode",
vec![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
}))],
),
(
"/token",
vec![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" }] }
}))],
),
]
}
/// 整链(含 entitlements 与 profile),便于替换中间任意一步。
pub fn full_routes() -> Vec<(&'static str, Vec<MockReply>)> {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-value",
"expires_in": 86_400
}))],
));
routes.push((
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
));
routes.push((
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
));
routes
}
/// 一个已过期的旧会话(刷新/校验用例用)。
pub 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,
)
}
/// 取 `AuthError::Response` 的诊断(不是这个变体就让用例炸掉,报出真实值)。
pub fn response_of(error: &AuthError) -> &ResponseDetail {
match error {
AuthError::Response(detail) => detail,
other => panic!("应是 Response,实际 {other:?}"),
}
}
/// 断言一段文案/事件里没有任何测试 token。
pub fn assert_no_credentials(text: &str) {
for forbidden in [
"xsts-token-value",
"xbl-token-value",
"live-access-token-value",
"live-refresh-token-value",
"device-code-secret-value",
"mc-access-token-value",
] {
assert!(
!text.contains(forbidden),
"文案泄漏了凭据 {forbidden}:{text}"
);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! `core::auth` 集成测试夹具:临时目录 + 进程内 HTTP 服务器(零额外依赖)。
//!
//! 为什么要 mock 服务器:微软/authlib 两条轨的正确性全在**报文级**(`XBL3.0 x=<uhs>;<xsts>`
//! 的拼接、XSTS 401 带 `XErr`、`/minecraft/profile` 404 的二义复判、Yggdrasil 的
//! `clientToken` 回传校验)——只有把整条链打到一个真实 socket 上,这些分支才被真的跑到。
//!
//! 服务器按 **path + 顺序队列**回应答:同一 path 的第 N 次请求用第 N 个应答
//! (设备码轮询要靠这个:`authorization_pending` → `slow_down` → 成功)。
//! 微软轨的 `MicrosoftAuth` / 路由表 / 断言助手拆在 [`microsoft`] 子模块里(M38)。
#![allow(dead_code)]
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub mod microsoft;
pub use microsoft::{
assert_no_credentials, auth_for, expired_session, full_routes, response_of, routes_until_login,
HTML_BLOCK_PAGE,
};
/// 唯一临时目录(进程内计数 + 时间戳,避免并行测试互踩)。
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let dir = std::env::temp_dir().join(format!("ull-auth-test-{tag}-{stamp}-{unique}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
dir
}
/// 一次 mock 应答。
#[derive(Debug, Clone)]
pub struct MockReply {
/// HTTP 状态码。
pub status: u16,
/// 响应体(JSON 文本或空)。
pub body: String,
/// `Content-Type`(默认 `application/json`;拦截页场景可改成 `text/html`)。
pub content_type: &'static str,
}
impl MockReply {
/// 200 + JSON。
pub fn json(value: serde_json::Value) -> Self {
Self {
status: 200,
body: value.to_string(),
content_type: "application/json",
}
}
/// 指定状态码 + JSON。
pub fn json_status(status: u16, value: serde_json::Value) -> Self {
Self {
status,
body: value.to_string(),
content_type: "application/json",
}
}
/// 指定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: String::new(),
content_type: "application/json",
}
}
/// 指定状态码 + HTML 拦截页(`Content-Type: text/html`)——M38 的网络分流用。
pub fn html(status: u16, body: &str) -> Self {
Self {
status,
body: body.to_string(),
content_type: "text/html; charset=utf-8",
}
}
/// 指定状态码 + 纯文本(网关页的另一种常见形状)。
pub fn text(status: u16, body: &str) -> Self {
Self {
status,
body: body.to_string(),
content_type: "text/plain",
}
}
}
/// 收到的一次请求(断言报文形状用)。
#[derive(Debug, Clone)]
pub struct Recorded {
/// 方法(大写)。
pub method: String,
/// 路径(不含 query)。
pub path: String,
/// 请求体(表单或 JSON 原文)。
pub body: String,
}
/// 进程内 HTTP/1.1 服务器(按 path 排队回应答)。
pub struct AuthMock {
addr: SocketAddr,
requests: Arc<Mutex<Vec<Recorded>>>,
handle: tokio::task::JoinHandle<()>,
}
impl AuthMock {
/// 启动服务器。`routes` 是 `(path, 该 path 依次使用的应答队列)`。
pub async fn start(routes: Vec<(&str, Vec<MockReply>)>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("绑定端口");
let addr = listener.local_addr().expect("取地址");
let requests: Arc<Mutex<Vec<Recorded>>> = Arc::new(Mutex::new(Vec::new()));
let queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>> = Arc::new(Mutex::new(
routes
.into_iter()
.map(|(path, replies)| (path.to_string(), replies.into()))
.collect(),
));
let recorded = Arc::clone(&requests);
let queues_for_task = Arc::clone(&queues);
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let recorded = Arc::clone(&recorded);
let queues = Arc::clone(&queues_for_task);
tokio::spawn(async move {
let _ = serve(stream, recorded, queues).await;
});
}
});
Self {
addr,
requests,
handle,
}
}
/// 基址(喂给 `MicrosoftEndpoints::all_on` / `AuthlibEndpoints` 的根)。
pub fn base(&self) -> String {
format!("http://{}", self.addr)
}
/// 端口。
pub fn port(&self) -> u16 {
self.addr.port()
}
/// 已收到的请求(按到达顺序)。
pub fn requests(&self) -> Vec<Recorded> {
self.requests
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
/// 找某 path 的请求体列表。
pub fn bodies_on(&self, path: &str) -> Vec<String> {
self.requests()
.into_iter()
.filter(|request| request.path == path)
.map(|request| request.body)
.collect()
}
/// 关停。
pub fn stop(self) {
self.handle.abort();
}
}
async fn serve(
mut stream: TcpStream,
recorded: Arc<Mutex<Vec<Recorded>>>,
queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>>,
) -> std::io::Result<()> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 2048];
// 读头
let header_end = loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(position) = find_headers_end(&buffer) {
break position;
}
};
let head = String::from_utf8_lossy(&buffer[..header_end]).to_string();
let content_length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
}
let body = String::from_utf8_lossy(
&buffer[header_end..(header_end + content_length).min(buffer.len())],
)
.to_string();
let mut first_line = head.lines().next().unwrap_or_default().split(' ');
let method = first_line.next().unwrap_or_default().to_ascii_uppercase();
let raw_path = first_line.next().unwrap_or("/");
let path = raw_path.split('?').next().unwrap_or("/").to_string();
recorded
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(Recorded {
method,
path: path.clone(),
body,
});
let reply = queues
.lock()
.unwrap_or_else(|error| error.into_inner())
.get_mut(&path)
.and_then(VecDeque::pop_front)
.unwrap_or_else(|| MockReply::status(500));
let mut response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
reply.status,
reason(reply.status),
reply.content_type,
reply.body.len()
)
.into_bytes();
response.extend_from_slice(reply.body.as_bytes());
stream.write_all(&response).await?;
stream.flush().await?;
Ok(())
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Status",
}
}