AGC认证错误改为顶层类别加类型化reason

- auth_error.rs:顶层从拍平变体收敛为类别变体,服务地址/网络失败/响应契约破损各占一个变体,不再拆成 7+2+5 个
- auth_error.rs:新增 ServerAddressReason、AuthNetworkReason、AuthResponseInvalidReason 三个 ts-rs 枚举,payload 改持类型化 reason
- auth_session.rs:校验与映射改用 server_address_rejected / response_invalid 收口,按类型化 reason 构造
This commit is contained in:
2026-10-02 02:42:17 +08:00
parent 9fe692ba87
commit 254f2767cc
2 changed files with 210 additions and 137 deletions
@@ -2,19 +2,13 @@
//!
//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
//!
//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、
//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP
//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的
//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支
//! 拿到的形状与 Rust 一致。
//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**,
//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、
//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用
//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。
//!
//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`),
//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。
//!
//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是
//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如
//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是
//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。
//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的
//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。
use serde::Serialize;
use ts_rs::TS;
@@ -25,21 +19,9 @@ use ts_rs::TS;
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum ClientAuthError {
// ---- 业务:用户自己能改,调用方给提示,不上报 ----
/// 服务地址为空或超长。
ServerAddressEmptyOrTooLong,
/// 服务地址不是合法 URL。
ServerAddressNotAUrl,
/// 服务地址带用户名 / 密码。
ServerAddressHasCredentials,
/// 服务地址带路径、查询或 fragment。
ServerAddressHasPathOrQueryOrFragment,
/// 非本机服务地址不是 HTTPS。
ServerAddressNotHttps,
/// 服务地址 scheme 不是 http(s)。
ServerAddressUnsupportedScheme,
/// 发布构建里服务地址不在当前构建渠道范围内。
ServerAddressOutsideChannel,
/// 本地前置校验:手机号为空、超长或不是纯数字。
/// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。
ServerAddressRejected(ServerAddressRejected),
/// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。
PhoneNumberInvalid,
/// 本地前置校验:密码为空。
PasswordMissing,
@@ -65,24 +47,14 @@ pub(crate) enum ClientAuthError {
PermissionDenied,
// ---- 系统:调用方处理不了,带上下文重抛 ----
/// 连接登录服务超时。
AuthNetworkTimeout,
/// 连接登录服务失败(DNS / 连接被拒等)。
AuthNetworkUnreachable,
/// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。
AuthNetworkFailure(AuthNetworkFailure),
/// 登录服务 5xx。
AuthServiceUnavailable(AuthServiceUnavailable),
/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
UnexpectedRejection(UnexpectedRejection),
/// 登录服务响应不是合法 JSON。
AuthResponseNotJson,
/// 登录服务响应不是预期结构(缺字段 / 类型不符)。
AuthResponseInvalidBody,
/// 登录 / 续期响应没有下发新的续期凭据。
AuthResponseMissingRefreshCookie,
/// 登录响应没有带上会话主体(用户身份)。
AuthResponseMissingUserIdentity,
/// 登录服务在响应体里显式拒绝(`ok: false`)。
AuthResponseServerRejected(AuthResponseServerRejected),
/// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。
AuthResponseInvalid(AuthResponseInvalid),
/// 本机登录凭据文件读写失败。
ClientSessionPersistFailed(ClientSessionPersistFailed),
/// 本机运行时会话安装 / 清理失败。
@@ -91,8 +63,67 @@ pub(crate) enum ClientAuthError {
AuthClientInitFailed,
}
// ---- 可枚举的细分原因:类型化字段,不是字符串 ----
/// 服务地址校验失败的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum ServerAddressReason {
/// 为空或超长。
EmptyOrTooLong,
/// 不是合法 URL。
NotAUrl,
/// 带用户名 / 密码。
HasCredentials,
/// 带路径、查询或 fragment。
HasPathOrQueryOrFragment,
/// 非本机地址不是 HTTPS。
NotHttps,
/// scheme 不是 http(s)。
UnsupportedScheme,
/// 发布构建里不在当前构建渠道范围内。
OutsideChannel,
}
/// 连接登录服务失败的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum AuthNetworkReason {
/// 超时。
Timeout,
/// DNS / 连接被拒 / 读响应失败等。
Unreachable,
}
/// 登录响应契约破损的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum AuthResponseInvalidReason {
/// 不是合法 JSON。
NotJson,
/// 不是预期结构(缺字段 / 类型不符 / 凭据格式无效)。
InvalidBody,
/// 没有下发新的续期凭据。
MissingRefreshCookie,
/// 没有带上会话主体(用户身份)。
MissingUserIdentity,
/// 响应体里显式拒绝(`ok: false`)。
ServerRejected,
}
// ---- 载荷:只装类型说不出来的事实 ----
/// 服务地址被拒的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct ServerAddressRejected {
pub(crate) reason: ServerAddressReason,
}
/// `/api/auth/entry` 返回 400 时服务端给的原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
@@ -120,8 +151,16 @@ pub(crate) struct PhoneCodeLoginRejected {
pub(crate) server_message: Option<String>,
}
/// 连接登录服务的传输层失败原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthNetworkFailure {
pub(crate) reason: AuthNetworkReason,
}
/// 登录服务 5xx 的状态码。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthServiceUnavailable {
@@ -137,11 +176,13 @@ pub(crate) struct UnexpectedRejection {
pub(crate) server_message: Option<String>,
}
/// 登录服务在响应体里显式拒绝时给的原文。
/// 登录响应契约破损的原因与服务端原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthResponseServerRejected {
pub(crate) struct AuthResponseInvalid {
pub(crate) reason: AuthResponseInvalidReason,
/// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。
pub(crate) server_message: Option<String>,
}
@@ -177,32 +218,10 @@ mod tests {
fn wire_variant_names_are_the_frontend_dispatch_keys() {
let cases = [
(
ClientAuthError::ServerAddressEmptyOrTooLong,
"serverAddressEmptyOrTooLong",
),
(
ClientAuthError::ServerAddressNotAUrl,
"serverAddressNotAUrl",
),
(
ClientAuthError::ServerAddressHasCredentials,
"serverAddressHasCredentials",
),
(
ClientAuthError::ServerAddressHasPathOrQueryOrFragment,
"serverAddressHasPathOrQueryOrFragment",
),
(
ClientAuthError::ServerAddressNotHttps,
"serverAddressNotHttps",
),
(
ClientAuthError::ServerAddressUnsupportedScheme,
"serverAddressUnsupportedScheme",
),
(
ClientAuthError::ServerAddressOutsideChannel,
"serverAddressOutsideChannel",
ClientAuthError::ServerAddressRejected(ServerAddressRejected {
reason: ServerAddressReason::NotHttps,
}),
"serverAddressRejected",
),
(ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"),
(ClientAuthError::PasswordMissing, "passwordMissing"),
@@ -233,10 +252,11 @@ mod tests {
(ClientAuthError::SmsCodeRejected, "smsCodeRejected"),
(ClientAuthError::SessionInvalidated, "sessionInvalidated"),
(ClientAuthError::PermissionDenied, "permissionDenied"),
(ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"),
(
ClientAuthError::AuthNetworkUnreachable,
"authNetworkUnreachable",
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Timeout,
}),
"authNetworkFailure",
),
(
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }),
@@ -249,24 +269,12 @@ mod tests {
}),
"unexpectedRejection",
),
(ClientAuthError::AuthResponseNotJson, "authResponseNotJson"),
(
ClientAuthError::AuthResponseInvalidBody,
"authResponseInvalidBody",
),
(
ClientAuthError::AuthResponseMissingRefreshCookie,
"authResponseMissingRefreshCookie",
),
(
ClientAuthError::AuthResponseMissingUserIdentity,
"authResponseMissingUserIdentity",
),
(
ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected {
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: None,
}),
"authResponseServerRejected",
"authResponseInvalid",
),
(
ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed {
@@ -301,6 +309,25 @@ mod tests {
assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" }));
}
#[test]
fn reason_fields_are_typed_enums_not_strings() {
let rejected = serde_json::to_value(ClientAuthError::ServerAddressRejected(
ServerAddressRejected {
reason: ServerAddressReason::HasPathOrQueryOrFragment,
},
))
.expect("serialize auth error");
assert_eq!(rejected["type"], "serverAddressRejected");
assert_eq!(rejected["reason"], "hasPathOrQueryOrFragment");
let network =
serde_json::to_value(ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Unreachable,
}))
.expect("serialize auth error");
assert_eq!(network["reason"], "unreachable");
}
#[test]
fn payload_variants_keep_machine_facts_and_server_text() {
let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable(
@@ -319,6 +346,15 @@ mod tests {
assert_eq!(rejection["status"], 409);
assert_eq!(rejection["serverMessage"], "冲突");
let invalid =
serde_json::to_value(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: Some("登录服务请求失败".to_string()),
}))
.expect("serialize auth error");
assert_eq!(invalid["reason"], "serverRejected");
assert_eq!(invalid["serverMessage"], "登录服务请求失败");
let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed(
ClientSessionPersistFailed {
detail: "磁盘只读".to_string(),
@@ -333,6 +369,9 @@ mod tests {
assert!(ClientAuthError::SessionInvalidated.is_authority_failure());
assert!(ClientAuthError::PermissionDenied.is_authority_failure());
assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure());
assert!(!ClientAuthError::AuthNetworkTimeout.is_authority_failure());
assert!(!ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Timeout,
})
.is_authority_failure());
}
}
@@ -5,9 +5,10 @@
//! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。
use crate::auth_error::{
AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError,
ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected,
RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection,
AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, AuthResponseInvalidReason,
AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, PasswordLoginRejected,
PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, ServerAddressReason,
ServerAddressRejected, UnexpectedRejection,
};
use crate::http_client::agc_main_site_client_builder;
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
@@ -241,6 +242,19 @@ fn runtime_session_error(message: impl Into<String>) -> ClientAuthError {
})
}
/// 服务地址校验失败统一收口成带类型化原因的变体。
fn server_address_rejected(reason: ServerAddressReason) -> ClientAuthError {
ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason })
}
/// 响应契约破损(不含服务端原文)统一收口。
fn response_invalid(reason: AuthResponseInvalidReason) -> ClientAuthError {
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason,
server_message: None,
})
}
/// 校验并归一化平台服务 origin。
///
/// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外
@@ -250,28 +264,35 @@ fn runtime_session_error(message: impl Into<String>) -> ClientAuthError {
pub(crate) fn validate_client_api_base_url(value: &str) -> Result<String, ClientAuthError> {
let trimmed = value.trim().trim_end_matches('/');
if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS {
return Err(ClientAuthError::ServerAddressEmptyOrTooLong);
return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong));
}
let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
let parsed =
Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ClientAuthError::ServerAddressHasCredentials);
return Err(server_address_rejected(ServerAddressReason::HasCredentials));
}
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
{
return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment);
return Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment,
));
}
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
match parsed.scheme() {
"https" => {}
"http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {}
"http" => return Err(ClientAuthError::ServerAddressNotHttps),
_ => return Err(ClientAuthError::ServerAddressUnsupportedScheme),
"http" => return Err(server_address_rejected(ServerAddressReason::NotHttps)),
_ => {
return Err(server_address_rejected(
ServerAddressReason::UnsupportedScheme,
))
}
}
if !cfg!(debug_assertions)
&& !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN)
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]")
{
return Err(ClientAuthError::ServerAddressOutsideChannel);
return Err(server_address_rejected(ServerAddressReason::OutsideChannel));
}
Ok(trimmed.to_string())
}
@@ -402,11 +423,11 @@ fn current_session_origin() -> Option<String> {
fn endpoint(origin: &str, route: &str) -> Result<String, ClientAuthError> {
let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/')))
.map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| ClientAuthError::ServerAddressNotAUrl)?;
.map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?;
for segment in route.trim_start_matches('/').split('/') {
if segment.is_empty() {
continue;
@@ -426,11 +447,12 @@ fn build_client() -> Result<reqwest::Client, ClientAuthError> {
}
fn network_error(error: &reqwest::Error) -> ClientAuthError {
if error.is_timeout() {
ClientAuthError::AuthNetworkTimeout
let reason = if error.is_timeout() {
AuthNetworkReason::Timeout
} else {
ClientAuthError::AuthNetworkUnreachable
}
AuthNetworkReason::Unreachable
};
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason })
}
fn error_message(body: &str) -> Option<String> {
@@ -536,14 +558,13 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA
}
fn response_data(body: &str) -> Result<Value, ClientAuthError> {
let value: Value =
serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?;
let value: Value = serde_json::from_str(body)
.map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?;
if value.get("ok").and_then(Value::as_bool) == Some(false) {
return Err(ClientAuthError::AuthResponseServerRejected(
AuthResponseServerRejected {
server_message: error_message(body),
},
));
return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: error_message(body),
}));
}
Ok(value.get("data").cloned().unwrap_or(value))
}
@@ -620,15 +641,18 @@ async fn request_auth(
CookiePolicy::Ignore => None,
};
// 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。
let text = response
.text()
.await
.map_err(|_| ClientAuthError::AuthNetworkUnreachable)?;
let text = response.text().await.map_err(|_| {
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Unreachable,
})
})?;
if !status.is_success() {
return Err(map_auth_failure(status, &text, auth_route(route)));
}
if matches!(policy, CookiePolicy::Require) && captured.is_none() {
return Err(ClientAuthError::AuthResponseMissingRefreshCookie);
return Err(response_invalid(
AuthResponseInvalidReason::MissingRefreshCookie,
));
}
Ok(AuthResponse {
data: response_data(&text)?,
@@ -667,7 +691,7 @@ async fn commit_authenticated_session(
identity_change: bool,
) -> Result<AuthUserPayload, ClientAuthError> {
if token.chars().count() > MAX_SECRET_CHARS {
return Err(ClientAuthError::AuthResponseInvalidBody);
return Err(response_invalid(AuthResponseInvalidReason::InvalidBody));
}
// 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。
let (user_id, known_user) = identity.resolve()?;
@@ -758,7 +782,9 @@ fn phone_is_valid(phone: &str) -> bool {
fn validated_session_user_id(user_id: &str) -> Result<String, ClientAuthError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(ClientAuthError::AuthResponseMissingUserIdentity);
return Err(response_invalid(
AuthResponseInvalidReason::MissingUserIdentity,
));
}
Ok(user_id.to_string())
}
@@ -790,7 +816,7 @@ async fn fetch_current_user(
)
.await?;
let me: MeResponse = serde_json::from_value(response.data)
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
Ok(me.user)
}
@@ -860,11 +886,11 @@ async fn refresh_session_inner(
}
};
let token: TokenResponse = serde_json::from_value(refreshed.data)
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
let install_token = token.token.clone();
let new_cookie = refreshed
.refresh_cookie
.ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?;
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
commit_authenticated_session(
app,
&session.api_base_url,
@@ -1009,7 +1035,7 @@ pub(crate) async fn send_client_phone_login_code(
)
.await?;
let payload: SendCodeResponse = serde_json::from_value(response.data)
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
Ok(ClientLoginCodeView {
cooldown_seconds: payload.cooldown_seconds,
expires_in_seconds: payload.expires_in_seconds,
@@ -1022,10 +1048,10 @@ async fn complete_login(
response: AuthResponse,
) -> Result<AuthUserPayload, ClientAuthError> {
let payload: TokenUserResponse = serde_json::from_value(response.data)
.map_err(|_| ClientAuthError::AuthResponseInvalidBody)?;
.map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?;
let cookie = response
.refresh_cookie
.ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?;
.ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?;
let user = commit_authenticated_session(
app,
origin,
@@ -1161,45 +1187,53 @@ mod tests {
#[test]
fn origins_reject_credentials_paths_and_plain_remote_http() {
// 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。
// 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。
assert_eq!(
validate_client_api_base_url("https://user:pass@example.com"),
Err(ClientAuthError::ServerAddressHasCredentials)
Err(server_address_rejected(ServerAddressReason::HasCredentials))
);
assert_eq!(
validate_client_api_base_url("https://example.com/api"),
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("https://example.com?a=1"),
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("https://example.com#frag"),
Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment)
Err(server_address_rejected(
ServerAddressReason::HasPathOrQueryOrFragment
))
);
assert_eq!(
validate_client_api_base_url("http://example.com"),
Err(ClientAuthError::ServerAddressNotHttps)
Err(server_address_rejected(ServerAddressReason::NotHttps))
);
assert_eq!(
validate_client_api_base_url("ftp://example.com"),
Err(ClientAuthError::ServerAddressUnsupportedScheme)
Err(server_address_rejected(
ServerAddressReason::UnsupportedScheme
))
);
assert_eq!(
validate_client_api_base_url(" "),
Err(ClientAuthError::ServerAddressEmptyOrTooLong)
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
);
assert_eq!(
validate_client_api_base_url("不是地址"),
Err(ClientAuthError::ServerAddressNotAUrl)
Err(server_address_rejected(ServerAddressReason::NotAUrl))
);
assert_eq!(
validate_client_api_base_url(&format!(
"https://example.com/{}",
"a".repeat(MAX_ORIGIN_CHARS)
)),
Err(ClientAuthError::ServerAddressEmptyOrTooLong)
Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong))
);
assert_eq!(
validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"),
@@ -1521,7 +1555,7 @@ mod tests {
// 是内部不变式,不是用户能理解的登录失败原因。
assert_eq!(
validated_session_user_id(" ").unwrap_err(),
ClientAuthError::AuthResponseMissingUserIdentity
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
);
}
@@ -1580,7 +1614,7 @@ mod tests {
// 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。
assert_eq!(
SessionIdentity::Login(payload.user).resolve().unwrap_err(),
ClientAuthError::AuthResponseMissingUserIdentity
response_invalid(AuthResponseInvalidReason::MissingUserIdentity)
);
// 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。
assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());