Skip to main content

yggdrasil/api/
auth.rs

1//! 认证相关的 Dioxus server function 与辅助函数。
2//!
3//! 提供注册、登录、登出、获取当前用户等接口,
4//! 通过 HttpOnly Cookie 维护会话,首个注册用户自动成为 admin。
5//! 也可通过 `ADMIN_*` 环境变量在启动时创建/同步初始管理员(env 为凭据源,
6//! 每次启动覆盖密码并确保 admin 角色),免去首次注册步骤——见 `sync_admin_from_env`。
7//! 所有 server function 均在 `#[server(Name, "/api")]` 下注册,供客户端与服务端调用。
8//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与 Cookie 写入。
9
10#![allow(clippy::unused_unit, deprecated)]
11
12use dioxus::prelude::*;
13#[cfg(feature = "server")]
14use http::header::{HeaderValue, SET_COOKIE};
15
16#[cfg(feature = "server")]
17use crate::api::error::AppError;
18#[cfg(feature = "server")]
19use crate::auth::session::get_session_from_ctx;
20#[cfg(feature = "server")]
21use crate::auth::{password, session};
22#[cfg(feature = "server")]
23use crate::db::pool::get_conn;
24use crate::models::user::PublicUser;
25#[cfg(feature = "server")]
26use crate::models::user::{SessionUser, UserRole};
27
28#[cfg(feature = "server")]
29fn validate_username(username: &str) -> Result<(), String> {
30    if username.len() < 3 || username.len() > 50 {
31        return Err("用户名长度必须在 3-50 字符之间".to_string());
32    }
33    if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
34        return Err("用户名只能包含字母、数字和下划线".to_string());
35    }
36    Ok(())
37}
38
39#[cfg(feature = "server")]
40pub(crate) fn validate_email(email: &str) -> Result<(), String> {
41    if !crate::utils::server::EMAIL_REGEX.is_match(email) {
42        return Err("邮箱格式不正确".to_string());
43    }
44    Ok(())
45}
46
47#[cfg(feature = "server")]
48pub(crate) fn validate_password(password: &str) -> Result<(), String> {
49    if password.len() < 8 {
50        return Err("密码长度至少 8 位".to_string());
51    }
52    Ok(())
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56/// 认证接口统一响应结构。
57pub struct AuthResponse {
58    /// 操作是否成功。
59    pub success: bool,
60    /// 提示信息。
61    pub message: String,
62    /// 登录成功后的会话 token(已废弃,实际通过 Cookie 传递)。
63    pub token: Option<String>,
64}
65
66/// 用户注册。
67///
68/// 校验用户名、邮箱、密码,首个注册用户自动设为 admin;
69/// 已有 admin 时返回 "Registration is closed"。
70/// Dioxus server function,注册在 `/api` 路径下。
71#[server(Register, "/api")]
72pub async fn register(
73    username: String,
74    email: String,
75    password: String,
76) -> Result<AuthResponse, ServerFnError> {
77    // 服务端构建时先进行严格限流检查。
78    #[cfg(feature = "server")]
79    {
80        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
81            let headers = ctx.parts_mut().headers.clone();
82            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
83            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
84                return Ok(AuthResponse {
85                    success: false,
86                    message: msg,
87                    token: None,
88                });
89            }
90        }
91    }
92
93    if let Err(e) = validate_username(&username) {
94        return Ok(AuthResponse {
95            success: false,
96            message: e,
97            token: None,
98        });
99    }
100    if let Err(e) = validate_email(&email) {
101        return Ok(AuthResponse {
102            success: false,
103            message: e,
104            token: None,
105        });
106    }
107    if let Err(e) = validate_password(&password) {
108        return Ok(AuthResponse {
109            success: false,
110            message: e,
111            token: None,
112        });
113    }
114
115    let client = get_conn().await.map_err(AppError::db_conn)?;
116
117    // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
118    let pw_for_hash = password.clone();
119    let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw_for_hash))
120        .await
121        .map_err(|_| AppError::Internal("密码处理任务失败"))?
122        .map_err(|_| AppError::Internal("密码处理失败"))?;
123
124    // 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
125    // 若已有 admin 或用户名/邮箱冲突,RETURNING 将返回空。
126    let result = client
127        .query_opt(
128            "INSERT INTO users (username, email, password_hash, role)
129             VALUES ($1, $2, $3, 'admin')
130             ON CONFLICT DO NOTHING
131             RETURNING id",
132            &[&username, &email, &password_hash],
133        )
134        .await
135        .map_err(AppError::query)?;
136
137    if result.is_some() {
138        return Ok(AuthResponse {
139            success: true,
140            message: "注册成功".to_string(),
141            token: None,
142        });
143    }
144
145    // 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
146    let admin_exists: bool = client
147        .query_one(
148            "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')",
149            &[],
150        )
151        .await
152        .map_err(AppError::query)?
153        .get(0);
154
155    let message = if admin_exists {
156        "Registration is closed".to_string()
157    } else {
158        "用户名或邮箱已存在".to_string()
159    };
160
161    Ok(AuthResponse {
162        success: false,
163        message,
164        token: None,
165    })
166}
167
168/// 用户登录。
169///
170/// 验证用户名/邮箱与密码,生成会话并写入 HttpOnly Cookie;
171/// 同一用户活跃会话数超过 `MAX_SESSIONS_PER_USER` 时删除最早会话。
172/// Dioxus server function,注册在 `/api` 路径下。
173#[server(Login, "/api")]
174pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> {
175    // 服务端构建时先进行严格限流检查。
176    #[cfg(feature = "server")]
177    {
178        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
179            let headers = ctx.parts_mut().headers.clone();
180            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
181            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
182                return Ok(AuthResponse {
183                    success: false,
184                    message: msg,
185                    token: None,
186                });
187            }
188        }
189    }
190
191    let mut client = get_conn().await.map_err(AppError::db_conn)?;
192
193    let row = match client
194        .query_opt(
195            "SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1 OR email = $1",
196            &[&username],
197        )
198        .await
199    {
200        Ok(Some(row)) => row,
201        Ok(None) => {
202            // 用户不存在时也执行一次 Argon2 verify,抹平「用户不存在」与
203            // 「密码错误」的响应时序差,防止通过响应时间枚举账号(L2)。
204            // 用固定合法哈希做 verify(必然失败),耗时与真实校验一致。
205            const DUMMY_HASH: &str =
206                "$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$n1S75LUQKaYJwaRl5bkFF/f/N1tLfRYR/7TuQxKP94c";
207            let dummy_pw = password.clone();
208            let _ = tokio::task::spawn_blocking(move || {
209                crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
210            })
211            .await;
212            return Ok(AuthResponse {
213                success: false,
214                message: "Invalid credentials".to_string(),
215                token: None,
216            });
217        }
218        Err(e) => {
219            return Err(AppError::query(e).into());
220        }
221    };
222
223    let password_hash: String = row.get("password_hash");
224    // Argon2 校验同样在 spawn_blocking 中执行。
225    let pw_for_verify = password.clone();
226    let hash_for_verify = password_hash.clone();
227    let valid = tokio::task::spawn_blocking(move || {
228        password::verify_password(&pw_for_verify, &hash_for_verify)
229    })
230    .await
231    .map_err(|_| AppError::Internal("密码处理任务失败"))?
232    .map_err(|_| AppError::Internal("密码处理失败"))?;
233
234    if !valid {
235        return Ok(AuthResponse {
236            success: false,
237            message: "Invalid credentials".to_string(),
238            token: None,
239        });
240    }
241
242    let user_id: i32 = row.get("id");
243    let token = session::generate_token();
244    let token_hash = session::hash_token(&token);
245    let expires_at = session::default_expiry();
246
247    let max_sessions = crate::api::settings::runtime_security_settings()
248        .await
249        .max_sessions_per_user
250        .max(1) as i64;
251
252    // 用事务 + 对 users 行加 FOR UPDATE 锁,串行化同一用户的并发登录,
253    // 避免 COUNT→DELETE→INSERT 之间的竞态导致超出上限(M1)。
254    let tx = client.transaction().await.map_err(AppError::query)?;
255    // 锁住该用户行,并发登录在此排队。
256    tx.execute("SELECT 1 FROM users WHERE id = $1 FOR UPDATE", &[&user_id])
257        .await
258        .map_err(AppError::query)?;
259
260    let session_count: i64 = tx
261        .query_one(
262            "SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
263            &[&user_id],
264        )
265        .await
266        .map_err(AppError::query)?
267        .get(0);
268
269    if session_count >= max_sessions {
270        tx.execute(
271            "DELETE FROM sessions WHERE id IN (
272                SELECT id FROM sessions
273                WHERE user_id = $1 AND expires_at > NOW()
274                ORDER BY created_at ASC
275                LIMIT 1
276            )",
277            &[&user_id],
278        )
279        .await
280        .map_err(AppError::query)?;
281    }
282
283    tx.execute(
284        "INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
285        &[&user_id, &token_hash, &None::<String>, &expires_at],
286    )
287    .await
288    .map_err(AppError::query)?;
289
290    tx.commit().await.map_err(AppError::query)?;
291
292    let cookie = session::session_cookie(&token, 30 * 24 * 60 * 60, session::cookie_secure().await);
293    // 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
294    if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
295        if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
296            ctx.add_response_header(SET_COOKIE, value);
297        }
298    }
299
300    Ok(AuthResponse {
301        success: true,
302        message: "登录成功".to_string(),
303        token: None,
304    })
305}
306
307/// 用户登出。
308///
309/// 清空客户端 session Cookie,并删除数据库中对应会话记录。
310/// Dioxus server function,注册在 `/api` 路径下。
311#[server(Logout, "/api")]
312pub async fn logout() -> Result<AuthResponse, ServerFnError> {
313    let token = get_session_from_ctx();
314
315    let client = get_conn().await.map_err(AppError::db_conn)?;
316
317    // 设置过期时间为 0 的 Cookie,通知浏览器清除会话。
318    let cookie = session::session_cookie("", 0, session::cookie_secure().await);
319    if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
320        if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
321            ctx.add_response_header(SET_COOKIE, value);
322        }
323    }
324
325    if let Some(t) = token {
326        let token_hash = session::hash_token(&t);
327        crate::cache::invalidate_session_user(&token_hash).await;
328        client
329            .execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash])
330            .await
331            .map_err(AppError::query)?;
332    }
333
334    Ok(AuthResponse {
335        success: true,
336        message: "登出成功".to_string(),
337        token: None,
338    })
339}
340
341#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
342/// 当前用户查询响应。
343pub struct CurrentUserResponse {
344    /// 当前已登录用户的公开信息;未登录时为 `None`。
345    pub user: Option<PublicUser>,
346}
347
348#[cfg(feature = "server")]
349/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
350///
351/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
352/// 缓存命中后仍回查 `users.session_generation`:若用户已被降级/封禁(generation 被
353/// bump),缓存的旧 SessionUser.generation 不再匹配,此时逐出缓存并视为未登录,
354/// 消除权限残留窗口(见 H2)。仅服务端内部使用,不会暴露给前端。
355pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
356    let token_hash = session::hash_token(token);
357
358    if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
359        // 缓存命中后校验世代号:bump 后该用户所有 session 应失效。
360        // 查询走主键,亚毫秒级,代价可接受。
361        let current_gen: Option<i32> = get_conn()
362            .await
363            .map_err(AppError::db_conn)?
364            .query_opt(
365                "SELECT session_generation FROM users WHERE id = $1",
366                &[&cached.id],
367            )
368            .await
369            .map_err(AppError::query)?
370            .map(|r| r.get::<_, i32>(0));
371        match current_gen {
372            Some(gen) if gen == cached.session_generation => return Ok(Some(cached)),
373            _ => {
374                // 世代不匹配或用户已删:逐出缓存,落入下方重新查询。
375                crate::cache::invalidate_session_user(&token_hash).await;
376            }
377        }
378    }
379
380    let client = get_conn().await.map_err(AppError::db_conn)?;
381
382    let row = client
383        .query_opt(
384            "SELECT u.id, u.username, u.email, u.display_name, u.avatar_url, u.role, u.created_at, u.session_generation
385             FROM sessions s
386             JOIN users u ON s.user_id = u.id
387             WHERE s.token_hash = $1 AND s.expires_at > NOW()",
388            &[&token_hash],
389        )
390        .await
391        .map_err(AppError::query)?;
392
393    let user = match row {
394        Some(row) => {
395            let role_str: String = row.get("role");
396            let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
397            Some(SessionUser {
398                id: row.get("id"),
399                username: row.get("username"),
400                email: row.get("email"),
401                display_name: row.get("display_name"),
402                avatar_url: row.get("avatar_url"),
403                role,
404                created_at: row.get("created_at"),
405                session_generation: row.get("session_generation"),
406            })
407        }
408        None => None,
409    };
410
411    if let Some(ref u) = user {
412        crate::cache::set_session_user(&token_hash, u.clone()).await;
413    }
414
415    Ok(user)
416}
417
418/// 获取当前登录用户的公开信息。
419///
420/// Dioxus server function,注册在 `/api` 路径下。
421#[server(GetCurrentUser, "/api")]
422pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
423    let token = match get_session_from_ctx() {
424        Some(t) => t,
425        None => return Ok(CurrentUserResponse { user: None }),
426    };
427
428    let user = get_user_by_token(&token).await?.map(PublicUser::from);
429
430    Ok(CurrentUserResponse { user })
431}
432
433#[cfg(feature = "server")]
434/// 获取当前登录用户并要求其为 admin,否则返回 401/403。
435///
436/// 供其它服务端接口内部调用。
437pub async fn get_current_admin_user() -> Result<SessionUser, AppError> {
438    let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
439
440    let session_user = get_user_by_token(&token)
441        .await
442        .map_err(AppError::query)?
443        .ok_or(AppError::Unauthorized("会话已过期"))?;
444
445    if session_user.role != UserRole::Admin {
446        return Err(AppError::Forbidden("权限不足"));
447    }
448
449    Ok(session_user)
450}
451
452/// 初始管理员配置(来自 `ADMIN_*` 环境变量)。
453///
454/// 不实现 `Debug`,避免日志意外泄露密码。
455#[cfg(feature = "server")]
456struct AdminEnvConfig {
457    username: String,
458    email: String,
459    password: String,
460}
461
462/// 从环境变量读取初始管理员配置(`ADMIN_USERNAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD`)。
463///
464/// 三个变量任一缺失或为空 → 返回 `None`(特性关闭)。
465/// 用户名/邮箱会 trim;密码原样保留(允许含空格)。
466#[cfg(feature = "server")]
467fn parse_admin_env() -> Option<AdminEnvConfig> {
468    let username = std::env::var("ADMIN_USERNAME").ok()?;
469    let username = username.trim().to_string();
470    if username.is_empty() {
471        return None;
472    }
473    let email = std::env::var("ADMIN_EMAIL").ok()?;
474    let email = email.trim().to_string();
475    if email.is_empty() {
476        return None;
477    }
478    let password = std::env::var("ADMIN_PASSWORD").ok()?;
479    if password.is_empty() {
480        return None;
481    }
482    Some(AdminEnvConfig {
483        username,
484        email,
485        password,
486    })
487}
488
489/// `ADMIN_*` 初始管理员环境变量是否全部就位(即启动同步特性是否激活)。
490///
491/// 激活时每次启动都会用 env 覆盖邮箱与密码——个人信息页据此显示提示横幅。
492#[cfg(feature = "server")]
493pub(crate) fn admin_env_active() -> bool {
494    parse_admin_env().is_some()
495}
496
497/// 启动时按 `ADMIN_*` 环境变量同步初始管理员(env 是超级管理员的凭据源)。
498///
499/// 语义(与 `BACKUP_*` 播种「仅首次生效」不同:这里**每次启动**都生效):
500/// 1. 三个变量任一缺失/为空 → 跳过(特性关闭,向后兼容)。
501/// 2. 变量值非法(不过 `validate_*` 校验)→ warn + 跳过,不阻断启动。
502/// 3. `ADMIN_USERNAME` 已存在 → 用 `ADMIN_PASSWORD` 覆盖其密码哈希
503///    (密码一致时跳过重哈希,避免每次启动改写行;覆盖时 bump
504///    `session_generation`,旧会话全部失效需重新登录);
505///    确保 role='admin'(恢复被降级/封禁的用户,触发 018 迁移的角色触发器);
506///    邮箱同步为 `ADMIN_EMAIL`(被其他用户占用时仅告警跳过邮箱)。
507/// 4. `ADMIN_USERNAME` 不存在 → 尝试创建为 admin。库中已存在其他 admin 时
508///    创建失败(`idx_one_admin` 部分唯一索引保证全库仅一个 admin),warn 提示。
509///
510/// 失败只告警不阻断启动(与 BACKUP_* 播种一致)。
511#[cfg(feature = "server")]
512pub(crate) async fn sync_admin_from_env(client: &tokio_postgres::Client) -> Result<(), AppError> {
513    let Some(cfg) = parse_admin_env() else {
514        return Ok(());
515    };
516    if let Err(e) = validate_username(&cfg.username) {
517        tracing::warn!(
518            "ADMIN_USERNAME={:?} 非法,跳过初始管理员同步: {e}",
519            cfg.username
520        );
521        return Ok(());
522    }
523    if let Err(e) = validate_email(&cfg.email) {
524        tracing::warn!("ADMIN_EMAIL={:?} 非法,跳过初始管理员同步: {e}", cfg.email);
525        return Ok(());
526    }
527    if let Err(e) = validate_password(&cfg.password) {
528        tracing::warn!("ADMIN_PASSWORD 不合法({e}),跳过初始管理员同步");
529        return Ok(());
530    }
531
532    // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行(与注册路径一致)。
533    let pw = cfg.password.clone();
534    let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw))
535        .await
536        .map_err(|_| AppError::Internal("初始管理员密码哈希任务失败"))?
537        .map_err(|_| AppError::Internal("初始管理员密码哈希失败"))?;
538
539    let row = client
540        .query_opt(
541            "SELECT id, email, role, password_hash FROM users WHERE username = $1",
542            &[&cfg.username],
543        )
544        .await
545        .map_err(AppError::query)?;
546
547    let Some(row) = row else {
548        // 用户名不存在:尝试创建为 admin(仅当库中尚无 admin 时成功)。
549        let created = client
550            .query_opt(
551                "INSERT INTO users (username, email, password_hash, role)
552                 VALUES ($1, $2, $3, 'admin')
553                 ON CONFLICT DO NOTHING
554                 RETURNING id",
555                &[&cfg.username, &cfg.email, &password_hash],
556            )
557            .await
558            .map_err(AppError::query)?;
559        if created.is_some() {
560            tracing::info!(
561                "初始管理员已从环境变量创建: username={}(登录成功后可从 .env 移除 ADMIN_* 变量)",
562                cfg.username
563            );
564        } else {
565            tracing::warn!(
566                "无法创建初始管理员 username={}(用户名/邮箱已被占用,或库中已存在其他 admin——全库仅允许一个 admin)。\
567                 可将 ADMIN_USERNAME 改为现有 admin 的用户名以同步其凭据",
568                cfg.username
569            );
570        }
571        return Ok(());
572    };
573
574    // 用户名已存在:env 覆盖密码、确保 admin 角色、同步邮箱。
575    let user_id: i32 = row.get("id");
576    let stored_hash: String = row.get("password_hash");
577    let stored_email: String = row.get("email");
578    let role: String = row.get("role");
579
580    // 密码与 env 一致时跳过重哈希(一次 Argon2 verify,代价可接受)。
581    let pw_for_verify = cfg.password.clone();
582    let same_password = tokio::task::spawn_blocking(move || {
583        password::verify_password(&pw_for_verify, &stored_hash)
584    })
585    .await
586    .map_err(|_| AppError::Internal("初始管理员密码校验任务失败"))?
587    .unwrap_or(false);
588    if !same_password {
589        // 凭据变更 → bump session_generation,旧会话全部失效,需用新密码重新登录。
590        client
591            .execute(
592                "UPDATE users SET password_hash = $1, session_generation = session_generation + 1
593                 WHERE id = $2",
594                &[&password_hash, &user_id],
595            )
596            .await
597            .map_err(AppError::query)?;
598        tracing::info!("初始管理员密码已从环境变量覆盖: username={}", cfg.username);
599    }
600
601    if role != "admin" {
602        // 018 迁移的角色变更触发器会在此 bump session_generation(降级期间的会话本已失效)。
603        client
604            .execute("UPDATE users SET role = 'admin' WHERE id = $1", &[&user_id])
605            .await
606            .map_err(AppError::query)?;
607        tracing::info!("初始管理员角色已恢复为 admin: username={}", cfg.username);
608    }
609
610    if stored_email != cfg.email {
611        let email_taken: bool = client
612            .query_one(
613                "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1 AND id <> $2)",
614                &[&cfg.email, &user_id],
615            )
616            .await
617            .map_err(AppError::query)?
618            .get(0);
619        if email_taken {
620            tracing::warn!(
621                "ADMIN_EMAIL={:?} 已被其他用户占用,跳过邮箱同步(密码/角色不受影响)",
622                cfg.email
623            );
624        } else {
625            client
626                .execute(
627                    "UPDATE users SET email = $1 WHERE id = $2",
628                    &[&cfg.email, &user_id],
629                )
630                .await
631                .map_err(AppError::query)?;
632        }
633    }
634
635    Ok(())
636}
637
638#[cfg(all(test, feature = "server"))]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn validate_username_valid() {
644        assert!(validate_username("admin").is_ok());
645        assert!(validate_username("user_123").is_ok());
646        assert!(validate_username("abc").is_ok());
647    }
648
649    #[test]
650    fn validate_username_too_short() {
651        assert!(validate_username("ab").is_err());
652    }
653
654    #[test]
655    fn validate_username_too_long() {
656        assert!(validate_username(&"a".repeat(51)).is_err());
657    }
658
659    #[test]
660    fn validate_username_max_length() {
661        assert!(validate_username(&"a".repeat(50)).is_ok());
662    }
663
664    #[test]
665    fn validate_username_special_chars() {
666        assert!(validate_username("user name").is_err());
667        assert!(validate_username("user@name").is_err());
668        assert!(validate_username("user-name").is_err());
669    }
670
671    #[test]
672    fn validate_username_unicode() {
673        assert!(validate_username("用户名").is_ok());
674    }
675
676    #[test]
677    fn validate_email_valid() {
678        assert!(validate_email("user@example.com").is_ok());
679        assert!(validate_email("a.b+c@domain.co").is_ok());
680    }
681
682    #[test]
683    fn validate_email_invalid() {
684        assert!(validate_email("notanemail").is_err());
685        assert!(validate_email("@domain.com").is_err());
686        assert!(validate_email("user@").is_err());
687        assert!(validate_email("user@.com").is_err());
688        assert!(validate_email("").is_err());
689    }
690
691    #[test]
692    fn validate_password_valid() {
693        assert!(validate_password("12345678").is_ok());
694        assert!(validate_password("a very long password with spaces").is_ok());
695    }
696
697    #[test]
698    fn validate_password_too_short() {
699        assert!(validate_password("1234567").is_err());
700    }
701
702    #[test]
703    fn validate_password_exactly_8() {
704        assert!(validate_password("12345678").is_ok());
705    }
706
707    #[test]
708    fn validate_password_empty() {
709        assert!(validate_password("").is_err());
710    }
711
712    /// 在给定环境变量集下运行闭包,结束后恢复原值(env 是进程全局状态,需 serial 隔离)。
713    fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
714        let saved: Vec<(String, Option<String>)> = vars
715            .iter()
716            .map(|(k, _)| (k.to_string(), std::env::var(k).ok()))
717            .collect();
718        for (k, v) in vars {
719            match v {
720                Some(v) => std::env::set_var(k, v),
721                None => std::env::remove_var(k),
722            }
723        }
724        f();
725        for (k, v) in saved {
726            match v {
727                Some(v) => std::env::set_var(k, v),
728                None => std::env::remove_var(k),
729            }
730        }
731    }
732
733    #[test]
734    #[serial_test::serial]
735    fn parse_admin_env_none_when_all_unset() {
736        with_env(
737            &[
738                ("ADMIN_USERNAME", None),
739                ("ADMIN_EMAIL", None),
740                ("ADMIN_PASSWORD", None),
741            ],
742            || assert!(parse_admin_env().is_none()),
743        );
744    }
745
746    #[test]
747    #[serial_test::serial]
748    fn parse_admin_env_none_when_any_missing() {
749        // 只缺 ADMIN_PASSWORD。
750        with_env(
751            &[
752                ("ADMIN_USERNAME", Some("admin")),
753                ("ADMIN_EMAIL", Some("admin@example.com")),
754                ("ADMIN_PASSWORD", None),
755            ],
756            || assert!(parse_admin_env().is_none()),
757        );
758        // 只缺 ADMIN_EMAIL。
759        with_env(
760            &[
761                ("ADMIN_USERNAME", Some("admin")),
762                ("ADMIN_EMAIL", None),
763                ("ADMIN_PASSWORD", Some("strongpass123")),
764            ],
765            || assert!(parse_admin_env().is_none()),
766        );
767        // 只缺 ADMIN_USERNAME。
768        with_env(
769            &[
770                ("ADMIN_USERNAME", None),
771                ("ADMIN_EMAIL", Some("admin@example.com")),
772                ("ADMIN_PASSWORD", Some("strongpass123")),
773            ],
774            || assert!(parse_admin_env().is_none()),
775        );
776    }
777
778    #[test]
779    #[serial_test::serial]
780    fn parse_admin_env_none_on_empty_values() {
781        with_env(
782            &[
783                ("ADMIN_USERNAME", Some("admin")),
784                ("ADMIN_EMAIL", Some("admin@example.com")),
785                ("ADMIN_PASSWORD", Some("")),
786            ],
787            || assert!(parse_admin_env().is_none()),
788        );
789        with_env(
790            &[
791                ("ADMIN_USERNAME", Some("   ")),
792                ("ADMIN_EMAIL", Some("admin@example.com")),
793                ("ADMIN_PASSWORD", Some("strongpass123")),
794            ],
795            || assert!(parse_admin_env().is_none()),
796        );
797    }
798
799    #[test]
800    #[serial_test::serial]
801    fn parse_admin_env_some_when_all_set() {
802        with_env(
803            &[
804                ("ADMIN_USERNAME", Some("  admin  ")),
805                ("ADMIN_EMAIL", Some(" admin@example.com ")),
806                ("ADMIN_PASSWORD", Some("  strong pass 123  ")),
807            ],
808            || {
809                let cfg = parse_admin_env().expect("三个变量齐全应返回 Some");
810                // 用户名/邮箱 trim,密码原样保留。
811                assert_eq!(cfg.username, "admin");
812                assert_eq!(cfg.email, "admin@example.com");
813                assert_eq!(cfg.password, "  strong pass 123  ");
814            },
815        );
816    }
817}