From f2149048fb7dad1d07d4f974b642826bebc80638 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 19:22:42 +0800 Subject: [PATCH] =?UTF-8?q?test(presenter):=20M132=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E7=BC=BA=20client=20id=20=E2=86=92=20=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E7=A0=81=E3=80=8D=E9=82=A3=E8=A1=8C=E9=92=89=E8=BF=9B=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88r1=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 实测:把 `start_login` / `start_browser_login` 里的 `auth_failure(&error)` 改回 `LoginFailure::Other`,编译通过、884 条测试全绿——也就是说「缺 id 会变成 MsaNotConfigured」 这件事此前只有代码可读。这条正是本 mission 要防的回退,补一条能抓它的锚。 新锚 `a_missing_client_id_reaches_the_login_panel_as_a_config_gap` 不只测 `auth_failure`, 而是**真的起两条登录流**(设备码 + 授权码),从 `JobSender` 收到的第一条 `HostTask::Login` 上断言 `failure == MsaNotConfigured`,并断言原文(含 `ULL_MSA_CLIENT_ID`)仍只在 `message` 里、页面画不出来。零网络:`from_env` 在 id 判定处就地早退,事件立刻到,不需要任何桩。 环境变量是进程级的,所以照 core 的做法串行 + panic 也还原;构建期注入过 id 的构建里 (本仓库 CI/本地都不注入)显式断言「只能是这个原因」后返回,不是 ignore/开关。 测试模块从 `accounts.rs` 拆成 `host/accounts/tests.rs`(`#[cfg(test)] mod tests;`,先例 `dto/accounts.rs`、`host/install.rs`):`accounts.rs` 400 → 299 行,拆完两边都远离 400 行硬线。 scope 已由塔台批准扩到 `crates/presenter/src/host/accounts/**`。 变异验证(最终代码上重跑): - 两处 `auth_failure(&error)` → `LoginFailure::Other`:`221 → 220 passed / 1 failed`(只有新锚红); - 还原:222 全绿。 顺手把 fn 指针数组换成一个 `assert_config_gap(name, start)` 辅助函数, `cargo clippy -D warnings` 的 `type_complexity` 不放过它。 --- crates/presenter/src/host/accounts.rs | 103 +---------- crates/presenter/src/host/accounts/tests.rs | 180 ++++++++++++++++++++ 2 files changed, 181 insertions(+), 102 deletions(-) create mode 100644 crates/presenter/src/host/accounts/tests.rs diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 0055162..3edcca3 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -296,105 +296,4 @@ fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result AuthError { - AuthError::Denied { - code: code.to_string(), - description: "bad".to_string(), - } - } - - fn network() -> AuthError { - AuthError::Network { - url: "https://x".to_string(), - detail: "dns".to_string(), - } - } - - #[test] - fn failure_codes_are_classified_from_variants_not_chinese_text() { - assert_eq!(classify(&network()), LoginFailure::Unreachable); - let whitelist = classify(&denied("AADSTS7000218")); - assert_eq!(whitelist, LoginFailure::NotWhitelisted); - assert_eq!(classify(&denied("invalid_grant")), LoginFailure::Denied); - assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other); - assert!(mentions_whitelist("AADSTS7000218")); - assert!(!mentions_whitelist("invalid_grant")); - } - - /// **M132**:只有「缺 client id」变成「需要配置」,网络/凭据失败不许跟着变(否则成噪音)。 - #[test] - fn only_a_missing_client_id_becomes_the_config_gap_code() { - let missing = auth_failure(&AuthError::Unsupported(String::new())); - assert_eq!(missing, LoginFailure::MsaNotConfigured); - assert_eq!(auth_failure(&network()), LoginFailure::Unreachable); - assert_eq!(auth_failure(&denied("invalid_grant")), LoginFailure::Denied); - } - - #[test] - fn offline_name_rule_matches_the_ui_contract() { - assert!(valid_offline_name("Steve")); - assert!(valid_offline_name("a_1")); - assert!(!valid_offline_name("ab"), "太短"); - assert!(!valid_offline_name("中文名字"), "非 ascii"); - assert!(!valid_offline_name("has space")); - assert!(!valid_offline_name("x")); - } - - #[test] - fn a_bad_offline_name_is_classified_not_stringified() { - let dir = std::env::temp_dir().join(format!("ull-name-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let env = HostEnv::at_root(&dir); - std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); - let failure = add_offline(&env, "ab").expect_err("太短要报错"); - assert_eq!(failure.code, AccountNoticeCode::OfflineNameInvalid); - assert!(!failure.detail.is_empty()); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn login_job_cancel_flag_is_shared() { - let job = LoginJob::new(); - let clone = job.clone(); - assert!(!clone.is_cancelled()); - job.cancel(); - assert!(clone.is_cancelled()); - } -} +mod tests; diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs new file mode 100644 index 0000000..d8c9be9 --- /dev/null +++ b/crates/presenter/src/host/accounts/tests.rs @@ -0,0 +1,180 @@ +//! 账户 host 的判据:入库/删除/名字规则、失败分类,以及**缺 client id 的上屏链路**(M132)。 +//! +//! ## 为什么这个文件要跟 `accounts.rs` 分开 +//! +//! `accounts.rs` 顶着 400 行硬线,而 M132 的锚必须**真的走一遍登录入口**(`start_login` / +//! `start_browser_login`)才能钉住「入口 → 原因码」那一行——只测 `auth_failure` 的话,把调用点 +//! 改回 `LoginFailure::Other` 仍然全绿(复审 r1 P2-1 实测)。测试拆出来是为了留出这段空间。 +//! +//! 拆法照抄仓库既有先例(`dto/accounts.rs`、`host/install.rs` 等):父文件里留 +//! `#[cfg(test)] mod tests;`,本文件 `use super::*;` 照旧拿到父模块的一切。 + +use std::time::Duration; + +use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_BUILD, CLIENT_ID_ENV}; +use unlucky_core::auth::AuthError; + +use super::*; + +use super::failure::mentions_whitelist; + +#[test] +fn time_display_matches_known_timestamps() { + assert_eq!(display_ms(0), ""); + // 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟) + assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53"); +} + +#[test] +fn offline_add_list_remove_round_trips() { + let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + + let name = add_offline(&env, "Steve").expect("加账户"); + assert_eq!(name, "Steve"); + let list = accounts(&env, None); + assert_eq!(list.len(), 1); + assert_eq!(list[0].kind, AccountKind::Offline); + assert!(!list[0].active); + + let active = accounts(&env, Some(list[0].id.as_str())); + assert!(active[0].active); + + let removed = remove(&env, "Steve").expect("删账户"); + assert_eq!(removed, "Steve"); + assert!(accounts(&env, None).is_empty()); + assert!(add_offline(&env, " ").is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +fn denied(code: &str) -> AuthError { + AuthError::Denied { + code: code.to_string(), + description: "bad".to_string(), + } +} + +fn network() -> AuthError { + AuthError::Network { + url: "https://x".to_string(), + detail: "dns".to_string(), + } +} + +#[test] +fn failure_codes_are_classified_from_variants_not_chinese_text() { + assert_eq!(classify(&network()), LoginFailure::Unreachable); + let whitelist = classify(&denied("AADSTS7000218")); + assert_eq!(whitelist, LoginFailure::NotWhitelisted); + assert_eq!(classify(&denied("invalid_grant")), LoginFailure::Denied); + assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other); + assert!(mentions_whitelist("AADSTS7000218")); + assert!(!mentions_whitelist("invalid_grant")); +} + +/// **M132**:只有「缺 client id」变成「需要配置」,网络/凭据失败不许跟着变(否则成噪音)。 +#[test] +fn only_a_missing_client_id_becomes_the_config_gap_code() { + let missing = auth_failure(&AuthError::Unsupported(String::new())); + assert_eq!(missing, LoginFailure::MsaNotConfigured); + assert_eq!(auth_failure(&network()), LoginFailure::Unreachable); + assert_eq!(auth_failure(&denied("invalid_grant")), LoginFailure::Denied); +} + +/// **M132 的入口锚(复审 r1 P2-1)**:缺 client id 时,**两条登录流**回给页面的那条事件里 +/// 必须带 `MsaNotConfigured`。 +/// +/// 抓得住(实测过的变异):把 `start_login` / `start_browser_login` 里的 +/// `auth_failure(&error)` 改回 `LoginFailure::Other`——那是页面退回「完整原因在启动器日志里」 +/// 的唯一开关,而 `auth_failure` 自己的单测对它一声不响。 +/// 零网络:`MicrosoftAuth::from_env` 在 id 判定处就地早退,连 HTTP 客户端都不建,所以这里 +/// 不需要任何桩,也不会因为没网而红。 +#[test] +fn a_missing_client_id_reaches_the_login_panel_as_a_config_gap() { + // 环境变量是**进程级**的:动它的用例必须串行(core 侧同一套写法),否则并行跑互相污染。 + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + // panic 也要还原:不还原的话后面所有用例都会在一个「没配 id」的进程里跑。 + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var(CLIENT_ID_ENV, value), + None => std::env::remove_var(CLIENT_ID_ENV), + } + } + } + let _restore = Restore(saved); + + if client_id_status().is_configured() { + // 构建期注入过 id(`ULL_MSA_CLIENT_ID= cargo build`)的构建里没有「缺配置」可言; + // 本仓库 CI 与本地都不注入,走到这里只允许是这个原因(不是「跳过」开关)。 + assert!( + CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), + "没配环境变量却报告已配置" + ); + return; + } + + assert_config_gap("device", start_login); + assert_config_gap("authcode", start_browser_login); +} + +/// 起一条登录流、等它回的第一条登录事件,并断言那是一条「缺配置」而不是通用失败。 +/// +/// 缺 id 时这条路径**零网络**(`from_env` 在 id 判定处早退),所以事件立刻到,不需要桩。 +fn assert_config_gap(name: &str, start: fn(HostEnv, JobSender, LoginJob)) { + let dir = std::env::temp_dir().join(format!("ull-msa-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (tx, rx) = super::super::job::channel(); + start(HostEnv::at_root(&dir), tx, LoginJob::new()); + let view = match rx.recv_timeout(Duration::from_secs(10)) { + Ok(HostTask::Login(view)) => view, + Ok(_) => panic!("{name}:缺 id 时第一条事件就该是登录失败"), + Err(error) => panic!("{name}:等不到登录事件({error})"), + }; + assert_eq!(view.failure, Some(LoginFailure::MsaNotConfigured), "{name}"); + // 原文(含要设的变量名)仍然只在 `message` 里:它只进日志,页面画不出来。 + assert!( + view.message.contains(CLIENT_ID_ENV), + "{name}: {}", + view.message + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn offline_name_rule_matches_the_ui_contract() { + assert!(valid_offline_name("Steve")); + assert!(valid_offline_name("a_1")); + assert!(!valid_offline_name("ab"), "太短"); + assert!(!valid_offline_name("中文名字"), "非 ascii"); + assert!(!valid_offline_name("has space")); + assert!(!valid_offline_name("x")); +} + +#[test] +fn a_bad_offline_name_is_classified_not_stringified() { + let dir = std::env::temp_dir().join(format!("ull-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + let failure = add_offline(&env, "ab").expect_err("太短要报错"); + assert_eq!(failure.code, AccountNoticeCode::OfflineNameInvalid); + assert!(!failure.detail.is_empty()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn login_job_cancel_flag_is_shared() { + let job = LoginJob::new(); + let clone = job.clone(); + assert!(!clone.is_cancelled()); + job.cancel(); + assert!(clone.is_cancelled()); +}