Merge branch 'feat/m132-msanotcfg-surface-missing-client-id'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
9dedaa6098
8 files changed
+383
-126
No files matched your search
@@ -278,33 +278,36 @@ fn countdown_line<'a>(
|
||||
|
||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||
///
|
||||
/// 两条修订叠在这里:
|
||||
/// 三点修订叠在这里:
|
||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小)。
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
|
||||
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = catalog.text(failure_body_key(failure)).to_string();
|
||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||
let actions = vec![
|
||||
buttons::tonal(
|
||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||
if !matches!(failure, LoginFailure::MsaNotConfigured) {
|
||||
actions.push(buttons::tonal(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceRetry),
|
||||
Account::StartDevice.into_page(),
|
||||
),
|
||||
));
|
||||
}
|
||||
actions.push(buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDialogClose),
|
||||
Account::Close.into_page(),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDialogClose),
|
||||
Account::Close.into_page(),
|
||||
),
|
||||
),
|
||||
];
|
||||
));
|
||||
super::widgets::alert(
|
||||
ui,
|
||||
super::widgets::Tone::Error,
|
||||
catalog.text(Key::AccountLoginFailed),
|
||||
catalog.text(failure_title_key(failure)),
|
||||
body,
|
||||
Some(code),
|
||||
actions,
|
||||
@@ -347,6 +350,18 @@ pub(super) fn failure_body_key(failure: LoginFailure) -> Key {
|
||||
LoginFailure::BadCredentials => Key::AccountFailureBadCredentials,
|
||||
LoginFailure::NoProfile => Key::AccountFailureNoProfile,
|
||||
LoginFailure::StoreFailed => Key::AccountFailureStore,
|
||||
LoginFailure::MsaNotConfigured => Key::AccountFailureMsaNotConfigured,
|
||||
LoginFailure::Other => Key::AccountFailureOther,
|
||||
}
|
||||
}
|
||||
|
||||
/// 原因码 → i18n **标题** key。
|
||||
///
|
||||
/// 只有「缺 client id」有专属标题(M132):标题写着 `Sign-in failed` 的话,用户会以为是自己
|
||||
/// 的账号/网络出了问题,而不会被指向那一项要补的配置。
|
||||
pub(super) fn failure_title_key(failure: LoginFailure) -> Key {
|
||||
match failure {
|
||||
LoginFailure::MsaNotConfigured => Key::AccountLoginNotConfigured,
|
||||
_ => Key::AccountLoginFailed,
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! | `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::{
|
||||
@@ -133,6 +135,55 @@ fn a_failure_with_a_provider_code_renders_english_and_the_code() {
|
||||
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() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::AccountNoticeCode;
|
||||
|
||||
use super::login::failure_body_key;
|
||||
use super::login::{failure_body_key, failure_title_key};
|
||||
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
||||
|
||||
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
||||
@@ -74,6 +74,7 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
}
|
||||
DevicePanel::Browser { link, .. } => out.push_str(link),
|
||||
DevicePanel::Failed { failure } => {
|
||||
out.push_str(catalog.text(failure_title_key(*failure)));
|
||||
out.push_str(catalog.text(failure_body_key(*failure)));
|
||||
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ macro_rules! account_entries {
|
||||
AccountFailureNoProfile => ("The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
|
||||
AccountFailureStore => ("Could not save the account to disk.", "账户没能写到磁盘上。"),
|
||||
AccountFailureOther => ("The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
|
||||
// M132:缺 client id 是**配置项缺失**,原因就是用户能执行的下一步,所以这一条
|
||||
// 必须自己说清「缺什么、怎么补」,不许退回上面那句「完整原因在日志里」。
|
||||
// 纪律:只写环境变量名与 `<id>` 占位,**不许出现任何真实 client id**;
|
||||
// 也不许写成「未支持 / unsupported」——登录链路是完整的。
|
||||
AccountLoginNotConfigured => ("Microsoft sign-in is not configured", "微软登录未配置"),
|
||||
AccountFailureMsaNotConfigured => (
|
||||
"This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.",
|
||||
"本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"
|
||||
),
|
||||
AccountDeviceRequesting => ("Contacting Microsoft…", "正在联系微软…"),
|
||||
AccountLoginSucceeded => ("Signed in as {name}", "已登录 {name}"),
|
||||
// ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 toast)
|
||||
@@ -111,3 +120,63 @@ macro_rules! account_entries {
|
||||
}
|
||||
|
||||
pub(crate) use account_entries;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Catalog, Key, Lang};
|
||||
|
||||
/// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。
|
||||
///
|
||||
/// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却
|
||||
/// 不给要设的变量名;也能抓住有人把某个真实 client id 写进词条。
|
||||
/// 抓不住:读起来顺不顺(那靠 review 与截图)。
|
||||
#[test]
|
||||
fn the_missing_client_id_copy_says_what_to_configure_in_both_languages() {
|
||||
for &lang in Lang::ALL {
|
||||
let catalog = Catalog::of(lang);
|
||||
let title = catalog.text(Key::AccountLoginNotConfigured);
|
||||
let body = catalog.text(Key::AccountFailureMsaNotConfigured);
|
||||
let all = format!("{title}\n{body}");
|
||||
|
||||
assert!(body.contains("ULL_MSA_CLIENT_ID"), "{}: {all}", lang.tag());
|
||||
assert!(
|
||||
body.contains("<id>"),
|
||||
"示例只许用占位:{}: {all}",
|
||||
lang.tag()
|
||||
);
|
||||
// 面板上是「标题 + 正文」一起:结论(未配置)给标题,怎么做给正文。
|
||||
assert!(
|
||||
all.to_lowercase().contains("not configured") || all.contains("未配置"),
|
||||
"要说清「未配置」:{}: {all}",
|
||||
lang.tag()
|
||||
);
|
||||
assert!(!body.to_lowercase().contains("unsupported"), "{all}");
|
||||
assert!(
|
||||
!body.contains("未支持") && !body.contains("未实现"),
|
||||
"{all}"
|
||||
);
|
||||
// 不许退回「完整原因在日志里」那句(那正是用户报的 bug)
|
||||
assert!(!body.contains("launcher log"), "{all}");
|
||||
assert!(!body.contains("启动器日志"), "{all}");
|
||||
// 硬约束:`ULL_MSA_CLIENT_ID=` 后面只许跟 `<id>` 占位——写进来一个真 id 就红。
|
||||
assert_eq!(
|
||||
body.matches("ULL_MSA_CLIENT_ID=").count(),
|
||||
body.matches("ULL_MSA_CLIENT_ID=<id>").count(),
|
||||
"示例里出现了真 id:{all}"
|
||||
);
|
||||
}
|
||||
for &lang in Lang::ALL {
|
||||
let catalog = Catalog::of(lang);
|
||||
let title = catalog.text(Key::AccountLoginNotConfigured);
|
||||
assert!(
|
||||
title.to_lowercase().contains("not configured") || title.contains("未配置"),
|
||||
"{}: {title}",
|
||||
lang.tag()
|
||||
);
|
||||
assert!(
|
||||
!title.contains('=') && !title.contains("ULL_MSA_CLIENT_ID"),
|
||||
"标题只报结论,变量名在正文里:{title}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。「为什么有这张表、它抓得住
|
||||
//! 什么」全在 `catalog.rs` 的 `mod golden;` 文档里——本文件只放数据,是因为每加一条 key 就多一行。
|
||||
|
||||
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 逐字快照,第一列是 `Key::name()`)。「为什么
|
||||
//! 有这张表、它抓得住什么」全在 `catalog.rs` 的 `mod golden;` 里;本文件只放数据。
|
||||
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("NavInstances", "Instances", "实例"),
|
||||
("NavDownload", "Versions", "版本管理"),
|
||||
@@ -381,6 +379,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
|
||||
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
|
||||
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
|
||||
("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"),
|
||||
("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"),
|
||||
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
|
||||
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
|
||||
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
//! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` /
|
||||
//! `detail` 里,**只进日志与详细信息,不上屏**。
|
||||
//!
|
||||
//! ## 唯一一个「说清原因」的例外(M132)
|
||||
//!
|
||||
//! [`LoginFailure::MsaNotConfigured`]。它上屏的**不是后端诊断串**,而是 catalog 里一条固定
|
||||
//! 文案(`Key::AccountFailureMsaNotConfigured` / `Key::AccountLoginNotConfigured`,不含任何
|
||||
//! 凭据、token 或内部细节)——「原文不上屏」这条纪律原样保留,页面仍拿不到、也画不出
|
||||
//! `message`/`detail`。允许它说清原因的理由只有一条:**原因本身就是用户能执行的下一步**
|
||||
//! (缺 `ULL_MSA_CLIENT_ID`,设上或构建时注入即可)。判据:没读过代码的用户碰上它,必须能
|
||||
//! 从界面自己看出「是缺一个配置」并知道怎么补,而不是去翻日志。其余一切(网络 / 拒绝 /
|
||||
//! 落盘…)仍旧只给码与分类文案,原文只进日志。
|
||||
//!
|
||||
//! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -37,6 +47,12 @@ pub enum LoginFailure {
|
||||
NoProfile,
|
||||
/// 落盘失败(账户库读写)
|
||||
StoreFailed,
|
||||
/// 微软轨的 client id 没配(`ULL_MSA_CLIENT_ID` 运行期/构建期两处都没有)——**配置缺失**,
|
||||
/// 不是登录被拒、也不是「没实现」(M132)。
|
||||
///
|
||||
/// 单独成码的理由见模块文档「唯一一个『说清原因』的例外」:这一类的原因就是用户能执行的
|
||||
/// 下一步,所以它的正文必须说清「要设哪个环境变量」,而不是「完整原因在日志里」。
|
||||
MsaNotConfigured,
|
||||
/// 其它(未知原因;原文只在 `message`)
|
||||
Other,
|
||||
}
|
||||
@@ -53,6 +69,7 @@ impl LoginFailure {
|
||||
Self::BadCredentials => "bad_credentials",
|
||||
Self::NoProfile => "no_profile",
|
||||
Self::StoreFailed => "store_failed",
|
||||
Self::MsaNotConfigured => "msa_not_configured",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//!
|
||||
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||||
//!
|
||||
//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板,
|
||||
//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +19,7 @@ use std::sync::Arc;
|
||||
use unlucky_core::auth::account::Credentials;
|
||||
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession};
|
||||
use unlucky_core::auth::offline::offline_meta;
|
||||
use unlucky_core::auth::{AccountStore, AuthResult};
|
||||
use unlucky_core::auth::{AccountStore, AuthError, AuthResult};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::CoreEvent;
|
||||
@@ -185,7 +188,7 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-login", move || {
|
||||
let auth = match microsoft_auth(&env) {
|
||||
Ok(auth) => auth,
|
||||
Err(message) => return fail(&tx, LoginFailure::Other, message),
|
||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||
};
|
||||
let tx_prompt = tx.clone();
|
||||
let result = block_on(auth.device_code_login(|prompt| {
|
||||
@@ -208,7 +211,7 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
spawn("msa-authcode", move || {
|
||||
let auth = match microsoft_auth(&env) {
|
||||
Ok(auth) => auth,
|
||||
Err(message) => return fail(&tx, LoginFailure::Other, message),
|
||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||
};
|
||||
let tx_prompt = tx.clone();
|
||||
let result = block_on(auth.authcode_login(|prompt| {
|
||||
@@ -225,14 +228,24 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||
}
|
||||
|
||||
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
|
||||
fn microsoft_auth(env: &HostEnv) -> Result<MicrosoftAuth, String> {
|
||||
MicrosoftAuth::from_env()
|
||||
.map(|auth| {
|
||||
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||
env.layout.logs(),
|
||||
)))
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
fn microsoft_auth(env: &HostEnv) -> AuthResult<MicrosoftAuth> {
|
||||
MicrosoftAuth::from_env().map(|auth| {
|
||||
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||
env.layout.logs(),
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
/// `microsoft_auth` 失败 → 原因码。
|
||||
///
|
||||
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
||||
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
||||
/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。
|
||||
fn auth_failure(error: &AuthError) -> LoginFailure {
|
||||
match error {
|
||||
AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured,
|
||||
other => classify(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。
|
||||
@@ -283,100 +296,4 @@ fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result<String, Str
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_core::auth::AuthError;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_codes_are_classified_from_variants_not_chinese_text() {
|
||||
assert_eq!(
|
||||
classify(&AuthError::Network {
|
||||
url: "https://x".to_string(),
|
||||
detail: "dns".to_string()
|
||||
}),
|
||||
LoginFailure::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&AuthError::Denied {
|
||||
code: "AADSTS7000218".to_string(),
|
||||
description: "request body must contain client_assertion".to_string(),
|
||||
}),
|
||||
LoginFailure::NotWhitelisted
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&AuthError::Denied {
|
||||
code: "invalid_grant".to_string(),
|
||||
description: "bad".to_string(),
|
||||
}),
|
||||
LoginFailure::Denied
|
||||
);
|
||||
assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other);
|
||||
assert!(mentions_whitelist("AADSTS7000218"));
|
||||
assert!(!mentions_whitelist("invalid_grant"));
|
||||
}
|
||||
|
||||
#[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-host-accounts-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;
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 账户 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_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 客户端都不建,所以这里
|
||||
/// 不需要任何桩,也不会因为没网而红。
|
||||
///
|
||||
/// **它要求一个「两处都没有 client id」的构建**:构建期烘焙了 id(`option_env!`)的构建里它
|
||||
/// `panic!` 而不是跳过——跳过就等于让这条锚悄悄失效(r2 复审实测过那种形态),红着喊人才
|
||||
/// 逼得人做决定(不带 id 构建,或给 `resolve_client_id` 加接缝后重写这条锚)。
|
||||
#[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<String>);
|
||||
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);
|
||||
|
||||
// 环境变量已经摘掉了,此时若仍报告「已配置」,来源只可能是构建期注入
|
||||
// (`ULL_MSA_CLIENT_ID=<id> cargo build` ⇒ `option_env!`),或者 `resolve_client_id` 的
|
||||
// 实现变了。**这种构建里必须红**:这条锚验证不了缺 id 路径,静默 `return` 就是让锚悄悄失效
|
||||
// (r2 复审实测:注入 id + 同一变异 → 全绿 22 passed,正是塔台最怕的「守门空跑」形态)。
|
||||
if let Some(source) = client_id_status().source() {
|
||||
panic!(
|
||||
"这条锚要求「两处都没有 client id」的构建,但二进制里 client id 已被报告为已配置\
|
||||
(source={source:?},环境变量 {CLIENT_ID_ENV} 已摘掉)。要么不带 \
|
||||
ULL_MSA_CLIENT_ID 构建,要么给 `unlucky_core::auth::microsoft::resolve_client_id` \
|
||||
加一个可注入的接缝,再按接缝重写这条锚。"
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user