- mock 增 `authlib-failed` 场景:AuthlibLoginChanged(Failed, NotAuthlibServer),原文只进日志 - `ULL_ACCOUNT_STATE=notice` 现在同时打开离线面板(截图能看到「表单 + 失败横幅」同屏) - 新增**截图专用**语言开关 `ULL_LANG=zh`(仅在 `--screenshot` 下生效):D30 要求错误码文案 中英各一张图,交互模式仍钉死 Lang::En(与产品行为一致)
329 lines
13 KiB
Rust
329 lines
13 KiB
Rust
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
|
||
//!
|
||
//! | 用例 | 抓得住什么 |
|
||
//! |---|---|
|
||
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
|
||
//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:后端中文诊断串又直接上屏了 |
|
||
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
|
||
|
||
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::{AuthlibPanel, DevicePanel, Form, Hint, 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,
|
||
}
|
||
}
|
||
|
||
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`),
|
||
/// 或页面把 DTO 的错误原文直接搬上屏。
|
||
#[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。
|
||
///
|
||
/// 抓得住:有人又把 `view.message`(core 的中文诊断串)画回面板上;或者码→i18n 的映射被摘。
|
||
/// 抓不住: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}");
|
||
}
|
||
|
||
/// 空态:**三条轨道都有入口**,不是一张白页;且三张卡的标题各不相同。
|
||
#[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,
|
||
remaining,
|
||
expired,
|
||
..
|
||
} => {
|
||
assert_eq!(code, "ABCD-EFGH");
|
||
assert_eq!(*remaining, Some(900));
|
||
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 {
|
||
remaining, expired, ..
|
||
} => {
|
||
assert_eq!(*remaining, Some(0));
|
||
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
|
||
}
|
||
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
|
||
));
|
||
}
|