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
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:
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);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user