Files
Unlucky-Launcher/crates/app-iced/src/pages/account/tests.rs
T
AskaEth f82bd395f8 fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路
官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用
注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回
`invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页,
启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。

- core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。
  预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions*
  原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。
  预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化
  原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。
- presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退,
  回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。
- i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。
- app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败
  面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。
- 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码;
  ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路);
  ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。
2026-11-08 20:23:42 +08:00

385 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:模型 + i18n 查表这一路又出现中文(视图层由类型禁画原文,见 `tests_text` 的边界说明) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
//! | `a_missing_client_id_tells_the_user_what_to_configure` | **M132**:缺 client id 又被画成通用失败(用户得翻日志才知道要设变量) |
//! | `a_network_failure_does_not_look_like_a_missing_config` | **M132 反向锚**:网络失败被误标成「未配置」,提示变噪音 |
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppEvent, AuthlibLoginView,
CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::model::{DevicePanel, Form, Page, Panel, Track};
use super::tests_text::{has_cjk, on_screen};
fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
AccountView {
id: format!("account:{name}"),
name: name.to_string(),
kind,
profile_id: "0f8f1d3c4b5a69788796a5b4c3d2e1f0".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active,
server_url: String::new(),
skin: None,
}
}
pub(super) fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
}
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 状态与视图模型里都不许出现。
///
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`)。
/// 视图层不在此列:面板态里已无 `message`/`detail` 字段,想画原文是编译错误。
#[test]
fn a_jwt_injected_into_events_never_reaches_the_page() {
let mut state = AppState::new();
state.accounts = vec![account("Steve", AccountKind::Microsoft, true)];
// **绕过构造器**直接摆一个带 JWT 的 DTO:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
failure: Some(LoginFailure::Denied),
..DeviceLoginView::new()
},
)));
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://littleskin.cn/api/yggdrasil/".to_string(),
server_name: String::new(),
message: format!("服务器回包异常 {JWT}"),
failure: Some(LoginFailure::Denied),
account_name: String::new(),
},
)));
// ① 状态层已经脱敏(这一条抓得住「摘掉 state 层 redact」的变异)
assert!(
!state.device_login.message.contains("eyJ"),
"{}",
state.device_login.message
);
assert!(
!state.authlib_login.message.contains("eyJ"),
"{}",
state.authlib_login.message
);
// ② 上屏文案更不可能有它(页面根本不画原文)
let text = on_screen(&build(&state, &Form::new()));
assert!(!text.contains("eyJ"), "视图里出现了 JWT:{text}");
assert!(!text.contains("⟪redacted⟫"), "脱敏占位符也不该上屏:{text}");
}
/// **语言泄漏(复审 r1 p1)**:带 AADSTS 码的失败事件 → 英文取样里不许有中文,且要带 code。
///
/// 抓得住:`failure_body_key` 的码→key 映射被摘/接错;DTO 或状态层把中文混进模型字段。
/// 抓不住(复审 r2 P2-2 实测过):视图层把原文画上屏——这条断言是**平行实现**。
/// 那道回归改由**类型**拦:`DevicePanel`/`AuthlibPanel` 已无 `message`/`detail` 字段。
/// 也抓不住:catalog 里某条英文译文本身写得差(那只能人工审图)。
#[test]
fn a_failure_with_a_provider_code_renders_english_and_the_code() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message:
"微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截"
.to_string(),
failure: Some(LoginFailure::NotWhitelisted),
..DeviceLoginView::new()
},
)));
let page = build(&state, &Form::new());
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面里出现了中文:{text}");
assert!(
text.contains("whitelist_pending"),
"面板要给稳定 code:{text}"
);
assert!(
text.to_lowercase().contains("approved"),
"要给出可读的英文原因:{text}"
);
// 第三方轨的失败同样只有 i18n + code
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://example.invalid/".to_string(),
server_name: String::new(),
message: "服务器不可达:dns error".to_string(),
failure: Some(LoginFailure::Unreachable),
account_name: String::new(),
},
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "{text}");
}
/// **M132 的主锚**:缺 client id 时**UI 模型**给出的结论必须是「这一项没配 + 怎么补」。
///
/// 抓得住:这一类失败又退回通用的「登录失败 / 完整原因在启动器日志里」(原 bug),
/// 或者文案只说「缺 id」却不告诉用户去设哪个变量。
/// 抓不住:排版与配色(那靠截图);也不证明视图真的照模型画(见 `tests_text` 的边界说明)。
#[test]
fn a_missing_client_id_tells_the_user_what_to_configure() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
// 后端原文照旧进这里(只该进日志):界面上一句都不许出现。
DeviceLoginView::failed_as(
LoginFailure::MsaNotConfigured,
"微软登录未配置:缺少 Azure 公共客户端 id",
),
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(
text.contains("not configured"),
"标题要说清「未配置」:{text}"
);
assert!(
text.contains("ULL_MSA_CLIENT_ID"),
"要给出具体要设的变量名:{text}"
);
assert!(text.contains("<id>"), "示例只许用占位,不许写值:{text}");
assert!(text.contains("msa_not_configured"), "{text}");
assert!(!has_cjk(&text), "英文界面里出现了中文:{text}");
// 不许退回「去翻日志」那句:那正是用户报的那个 bug。
assert!(
!text.contains("full reason is in the launcher log"),
"{text}"
);
}
/// **反向锚(M132)**:普通网络失败不许也变成「未配置」——否则提示变噪音,用户会学会无视它。
#[test]
fn a_network_failure_does_not_look_like_a_missing_config() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(LoginFailure::Unreachable, "连不上服务器:dns error"),
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(text.contains("unreachable"), "{text}");
assert!(text.to_lowercase().contains("could not reach"), "{text}");
assert!(!text.to_lowercase().contains("not configured"), "{text}");
assert!(!text.contains("ULL_MSA_CLIENT_ID"), "{text}");
assert!(text.contains("Sign-in failed"), "标题仍是通用失败:{text}");
}
/// 空态:**三条轨道都有入口**,不是一张白页;且三张卡的标题各不相同。
#[test]
fn an_empty_account_list_offers_all_three_tracks() {
let state = AppState::new();
let page = build(&state, &Form::new());
assert!(page.is_empty());
assert_eq!(page.panel, Panel::None);
assert!(page.active_row().is_none());
let mut keys: Vec<&str> = Track::ALL
.iter()
.map(|track| track.title_key().name())
.collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), 3);
}
/// 设备码:**倒计时是人话格式(mm:ss)**,过期后是「已过期」而不是继续转圈。
#[test]
fn the_device_code_panel_shows_a_countdown_and_expires() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, ""),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
code,
countdown,
expired,
..
} => {
assert_eq!(code, "ABCD-EFGH");
assert!(
countdown.contains("15:00"),
"倒计时要画成 mm:ss:{countdown}"
);
assert!(!expired);
}
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(text.contains("15:00"), "倒计时要画成 mm:ss:{text}");
assert!(!text.contains("900s"), "不再是 `900s left`:{text}");
// 起点往前挪 20 分钟 ⇒ 过期
state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1_200));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
countdown, expired, ..
} => {
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
}
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
}
// 授权码流也走同一条倒计时(没有用户码时是 Browser 面板)
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::browser_waiting("https://login.microsoftonline.com/x", 300, None),
)));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Browser { .. }));
}
/// 没有皮肤 = 首字母块(不是白块、不是假皮肤);坏图也必须回落;无皮肤时**明说**。
#[test]
fn a_third_party_account_falls_back_to_the_monogram() {
let mut state = AppState::new();
state.accounts = vec![
account("Alex", AccountKind::Authlib, true),
account("Steve", AccountKind::Microsoft, false),
];
let page = build(&state, &Form::new());
assert!(
page.rows.iter().all(|row| row.head.is_none()),
"没有皮肤就不许有图"
);
assert!(page.rows.iter().all(|row| !row.monogram.is_empty()));
assert!(page.active_skin().is_none(), "皮肤预览区没有图就不画图");
let text = on_screen(&page);
assert!(!has_cjk(&text));
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 panic、不白块)
state.accounts[0].skin = Some(SkinImage {
png: b"not a png".to_vec(),
source: "test".to_string(),
});
let page = build(&state, &Form::new());
assert!(page.rows[0].head.is_none());
}
/// 离线档案的说明**必须**带「无法连接启用正版验证的服务器」这条限定(中英都要)。
#[test]
fn the_offline_track_keeps_the_agreed_wording() {
for lang in Lang::ALL {
let catalog = lang.catalog();
for key in [Key::AccountTrackOfflineBody, Key::AccountOfflineWarning] {
let text = catalog.text(key);
assert!(
text.contains("cannot join servers that require a genuine Minecraft account")
|| text.contains("无法连接启用正版验证的服务器"),
"{} 的 {key:?} 丢了「仅本地/隔离网络测试用」口径:{text}",
lang.tag()
);
assert!(
!text.contains("migration") && !text.contains("迁移"),
"不许改写成「兼容旧启动器迁移」:{text}"
);
}
}
}
/// **离线轨的失败必须可见**(复审 r1 P2-2):横幅进模型、文案走 i18n、原文不上屏。
#[test]
fn a_failed_offline_add_is_visible() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
AccountNotice::new(
AccountNoticeCode::OfflineNameInvalid,
"ab",
"离线名字必须是 3–16 位字母/数字/下划线,收到 \"ab\"",
),
))));
let page = build(&state, &Form::new());
let notice = page.notice.as_ref().expect("失败要有横幅");
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
assert!(notice.failure, "失败横幅要用 error 配色");
let text = on_screen(&page);
assert!(text.contains("3–16"), "要说清规则:{text}");
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
}
/// 表单状态:三条轨道各开各的,密码关面板即清,三卡默认收起。
#[test]
fn the_form_tracks_are_independent() {
let mut form = Form::new();
assert!(!form.section_open, "有账号时三卡默认收起(P2-4)");
form.open(Track::Offline);
assert_eq!(form.panel, Panel::Offline);
assert!(!form.offline_ready(), "空名字不能提交");
form.offline_name = "ab".to_string();
assert!(
!form.offline_ready(),
"太短也不许提交(与 host 同一条规则)"
);
assert!(
!unlucky_presenter::dto::valid_offline_name("ab"),
"UI 判据 = host 那份规则"
);
form.offline_name = "Alex".to_string();
assert!(form.offline_ready());
form.open(Track::Authlib);
assert!(!form.authlib_ready(), "地址/登录名/密码缺一不可");
form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
form.login = "alex".to_string();
form.password = "hunter2".to_string();
assert!(form.authlib_ready());
form.clear_secret();
assert!(form.password.is_empty());
assert!(!form.authlib_ready());
}
/// 登录阶段的可判据性:Requesting / Waiting / Failed 各自映射到不同面板。
#[test]
fn login_phases_map_to_distinct_panels() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..DeviceLoginView::new()
},
)));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Requesting));
// 进行中也只有 i18n(不再复读 presenter 的中文状态串)
assert!(!has_cjk(&on_screen(&page)));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed("HTTP 403"),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Failed { .. }
));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::new(),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Hidden
));
}