Files
Unlucky-Launcher/crates/core/tests/auth/microsoft_msalogin.rs
T
AskaEth a40b3a5940 fix(msa): 浏览器登录按钮在生产路径真的置位 browser_first(复审 r1 收口)
复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。

- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
  `.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
  模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
  把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
  `/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
  `TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
  删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
  msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
  `callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
  回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
  (下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
  「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
  文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
  视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
  下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。

门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
2026-11-08 20:23:44 +08:00

275 lines
12 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.
//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。
//!
//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回
//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页,
//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。
//!
//! 本文件钉四件事(都是能给答案「能不能变红」的):
//!
//! | 守卫 | 用例 | 抓到什么 |
//! |---|---|---|
//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) |
//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) |
//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) |
//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 |
//!
//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。
const INVALID_REDIRECT_PAGE: &str =
"error=invalid_request&error_description=The+provided+value+for+the+input+\
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
+redirect+URI+registered+for+this+client+application.";
/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。
const SIGN_IN_PAGE: &str = "<!DOCTYPE html><html><head><title>Sign in to your account</title>\
</head><body><form id=\"loginForm\"></form></body></html>";
/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。
type Seen = Arc<Mutex<Option<(String, Option<BrowserFallbackReason>)>>>;
fn policy(state: &str) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(2_000),
bind_attempts: 1,
port: None,
state: Some(state.to_string()),
}
}
/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。
fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec<MockReply>)> {
let mut routes = full_routes();
routes.push(("/authorize", vec![reply]));
routes
}
fn auth(base: &str, browser_first: bool) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(browser_first)
}
/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。
#[tokio::test]
async fn unregistered_redirect_falls_back_to_the_device_code() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
400,
INVALID_REDIRECT_PAGE,
)))
.await;
let seen: Seen = Arc::new(Mutex::new(None));
let seen_for_device = Arc::clone(&seen);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"),
move |prompt, fallback| {
*seen_for_device.lock().unwrap() =
Some((prompt.user_code.clone(), fallback.map(|note| note.reason)));
},
)
.await
.expect("必须回退到设备码并成功");
// ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文)
let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示");
assert_eq!(user_code, "ABCD-1234");
assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered));
assert_eq!(
reason.map(BrowserFallbackReason::code),
Some("redirect_not_registered")
);
assert_eq!(outcome.session.profile_name, "Steve");
let note = outcome.browser.expect("必须带回退说明");
assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered);
// 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。
assert!(!note.detail.is_empty());
// ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」)
assert_eq!(
outcome.listeners_opened, 0,
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
);
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
assert_eq!(
mock.bodies_on("/authorize").len(),
1,
"浏览器优先路必须真的发一次回环注册预检"
);
mock.stop();
}
/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。
#[tokio::test]
async fn device_code_default_never_opens_a_listening_port() {
let mock = AuthMock::start(full_routes()).await;
let device_seen = Arc::new(Mutex::new(None));
let device_flag = Arc::clone(&device_seen);
let outcome = auth(&mock.base(), false)
.browser_then_device_login(
|_| panic!("默认路不许碰浏览器回调"),
move |prompt, fallback| {
*device_flag.lock().unwrap() = Some(prompt.user_code.clone());
assert!(fallback.is_none(), "没走浏览器就不该有回退说明");
},
)
.await
.expect("设备码登录成功");
assert_eq!(
device_seen.lock().unwrap().clone().as_deref(),
Some("ABCD-1234")
);
assert!(outcome.browser.is_none());
assert_eq!(
outcome.session.profile_id,
crate::support::microsoft::PROFILE_ID
);
assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口");
// 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳)
assert!(mock.bodies_on("/authorize").is_empty());
mock.stop();
}
/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。
#[tokio::test]
async fn registered_loopback_still_runs_the_auth_code_flow() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?code=the-auth-code&state=state-abc");
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200);
});
},
|_prompt, _note| panic!("登记了回环就不该回退设备码"),
)
.await
.expect("授权码流应当走通");
assert_eq!(outcome.session.profile_name, "Steve");
assert!(outcome.browser.is_none(), "走通了就不该有回退说明");
assert_eq!(
outcome.listeners_opened, 1,
"登记了回环就应当真的绑端口(否则加速路名存实亡)"
);
// 换 token 用的是**授权码** grant(不是设备码那条)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "{token_bodies:?}");
assert!(token_bodies[0].contains("grant_type=authorization_code"));
mock.stop();
}
/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。
#[tokio::test]
async fn user_cancel_on_the_browser_page_does_not_fall_back() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let error = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
},
|_prompt, _note| panic!("用户取消后不许自动换流"),
)
.await
.expect_err("取消应当是最终结果");
assert_eq!(error, AuthError::Canceled);
assert!(
mock.bodies_on("/devicecode").is_empty(),
"取消之后不该再去要设备码"
);
mock.stop();
}
/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。
#[tokio::test]
async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() {
// 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。
let mock = AuthMock::start(full_routes()).await;
let mut endpoints = MicrosoftEndpoints::all_on(&mock.base());
endpoints.authorize = "http://127.0.0.1:1/authorize".to_string();
let reason = Arc::new(Mutex::new(None));
let reason_flag = Arc::clone(&reason);
let outcome = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(endpoints)
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(true)
.browser_then_device_login(
|_prompt| panic!("预检失败时不许弹浏览器"),
move |_prompt, fallback| {
*reason_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("预检失败不该拦住设备码");
assert_eq!(
*reason.lock().unwrap(),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(outcome.session.profile_name, "Steve");
assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口");
mock.stop();
}
/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。
#[tokio::test]
async fn aadsts50011_page_is_not_treated_as_registered() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
200,
"AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured",
)))
.await;
let note = Arc::new(Mutex::new(None));
let note_flag = Arc::clone(&note);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_| panic!("AADSTS50011 的页面不许当已登记"),
move |_prompt, fallback| {
*note_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("回退设备码后应当成功");
assert_eq!(
*note.lock().unwrap(),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
mock.stop();
}