1#![allow(clippy::unused_unit, deprecated)]
11
12use crate::api::error::ApiError;
13#[cfg(feature = "server")]
14use http::header::{HeaderValue, SET_COOKIE};
15use leptos::prelude::*;
16
17#[cfg(feature = "server")]
18use crate::api::error::AppError;
19#[cfg(feature = "server")]
20use crate::auth::session::get_session_from_ctx;
21#[cfg(feature = "server")]
22use crate::auth::{password, session};
23#[cfg(feature = "server")]
24use crate::db::pool::get_conn;
25use crate::models::user::PublicUser;
26#[cfg(feature = "server")]
27use crate::models::user::{SessionUser, UserRole};
28
29#[cfg(feature = "server")]
30fn validate_username(username: &str) -> Result<(), String> {
31 let len = username.chars().count();
32 if !(3..=50).contains(&len) {
33 return Err("用户名长度必须在 3-50 字符之间".to_string());
34 }
35 if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
36 return Err("用户名只能包含字母、数字和下划线".to_string());
37 }
38 Ok(())
39}
40
41#[cfg(feature = "server")]
42pub(crate) fn validate_email(email: &str) -> Result<(), String> {
43 if !crate::utils::server::EMAIL_REGEX.is_match(email) {
44 return Err("邮箱格式不正确".to_string());
45 }
46 Ok(())
47}
48
49#[cfg(feature = "server")]
50const MAX_PASSWORD_BYTES: usize = 256;
53
54#[cfg(feature = "server")]
55pub(crate) fn validate_password(password: &str) -> Result<(), String> {
56 if password.len() < 8 {
57 return Err("密码长度至少 8 位".to_string());
58 }
59 if password.len() > MAX_PASSWORD_BYTES {
60 return Err("密码长度不能超过 256 位".to_string());
61 }
62 Ok(())
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct AuthResponse {
68 pub success: bool,
70 pub message: String,
72 pub token: Option<String>,
74}
75
76#[server(Register, endpoint = "register", input = leptos::server_fn::codec::Json)]
83pub async fn register(
84 username: String,
85 email: String,
86 password: String,
87) -> Result<AuthResponse, ApiError> {
88 #[cfg(feature = "server")]
90 {
91 if let Some(headers) = crate::request_ctx::request_headers() {
92 let ip = crate::api::rate_limit::get_client_ip(&headers).await;
93 if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
94 return Ok(AuthResponse {
95 success: false,
96 message: msg,
97 token: None,
98 });
99 }
100 }
101 }
102
103 if let Err(e) = validate_username(&username) {
104 return Ok(AuthResponse {
105 success: false,
106 message: e,
107 token: None,
108 });
109 }
110 if let Err(e) = validate_email(&email) {
111 return Ok(AuthResponse {
112 success: false,
113 message: e,
114 token: None,
115 });
116 }
117 if let Err(e) = validate_password(&password) {
118 return Ok(AuthResponse {
119 success: false,
120 message: e,
121 token: None,
122 });
123 }
124
125 let client = get_conn().await.map_err(AppError::db_conn)?;
126
127 let has_users: bool = client
131 .query_one("SELECT EXISTS (SELECT 1 FROM users)", &[])
132 .await
133 .map_err(AppError::query)?
134 .get(0);
135 if has_users {
136 return Ok(registration_closed());
137 }
138
139 let pw_for_hash = password.clone();
141 let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw_for_hash))
142 .await
143 .map_err(|_| AppError::Internal("密码处理任务失败"))?
144 .map_err(|_| AppError::Internal("密码处理失败"))?;
145
146 let result = client
150 .query_opt(
151 "INSERT INTO users (username, email, password_hash, role)
152 SELECT $1, $2, $3, 'admin'
153 WHERE NOT EXISTS (SELECT 1 FROM users)
154 ON CONFLICT DO NOTHING
155 RETURNING id",
156 &[&username, &email, &password_hash],
157 )
158 .await
159 .map_err(AppError::query)?;
160
161 Ok(if result.is_some() {
163 AuthResponse {
164 success: true,
165 message: "注册成功".to_string(),
166 token: None,
167 }
168 } else {
169 registration_closed()
170 })
171}
172
173#[cfg(feature = "server")]
174fn registration_closed() -> AuthResponse {
175 AuthResponse {
176 success: false,
177 message: "Registration is closed".to_string(),
178 token: None,
179 }
180}
181
182#[server(Login, endpoint = "login", input = leptos::server_fn::codec::Json)]
188pub async fn login(username: String, password: String) -> Result<AuthResponse, ApiError> {
189 #[cfg(feature = "server")]
191 {
192 if let Some(headers) = crate::request_ctx::request_headers() {
193 let ip = crate::api::rate_limit::get_client_ip(&headers).await;
194 if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
195 return Ok(AuthResponse {
196 success: false,
197 message: msg,
198 token: None,
199 });
200 }
201 }
202 }
203
204 let mut client = get_conn().await.map_err(AppError::db_conn)?;
205
206 let row = match client
207 .query_opt(
208 "SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1 OR email = $1",
209 &[&username],
210 )
211 .await
212 {
213 Ok(Some(row)) => row,
214 Ok(None) => {
215 const DUMMY_HASH: &str =
219 "$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$n1S75LUQKaYJwaRl5bkFF/f/N1tLfRYR/7TuQxKP94c";
220 let dummy_pw = password.clone();
221 let _ = tokio::task::spawn_blocking(move || {
222 crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
223 })
224 .await;
225 return Ok(AuthResponse {
226 success: false,
227 message: "Invalid credentials".to_string(),
228 token: None,
229 });
230 }
231 Err(e) => {
232 return Err(AppError::query(e).into());
233 }
234 };
235
236 let password_hash: String = row.get("password_hash");
237 let pw_for_verify = password.clone();
239 let hash_for_verify = password_hash.clone();
240 let valid = tokio::task::spawn_blocking(move || {
241 password::verify_password(&pw_for_verify, &hash_for_verify)
242 })
243 .await
244 .map_err(|_| AppError::Internal("密码处理任务失败"))?
245 .map_err(|_| AppError::Internal("密码处理失败"))?;
246
247 if !valid {
248 return Ok(AuthResponse {
249 success: false,
250 message: "Invalid credentials".to_string(),
251 token: None,
252 });
253 }
254
255 let role_str: String = row.get("role");
258 if !matches!(UserRole::from_str(&role_str), Some(UserRole::Admin)) {
259 return Ok(AuthResponse {
260 success: false,
261 message: "Invalid credentials".to_string(),
262 token: None,
263 });
264 }
265
266 let user_id: i32 = row.get("id");
267 let token = session::generate_token();
268 let token_hash = session::hash_token(&token);
269 let expires_at = session::default_expiry();
270
271 let max_sessions = crate::api::settings::runtime_security_settings()
272 .await
273 .max_sessions_per_user
274 .max(1) as i64;
275
276 let tx = client.transaction().await.map_err(AppError::query)?;
279 let still_valid = tx
282 .query_opt(
283 "SELECT 1 FROM users WHERE id = $1 AND password_hash = $2 AND role = 'admin' FOR UPDATE",
284 &[&user_id, &password_hash],
285 )
286 .await
287 .map_err(AppError::query)?
288 .is_some();
289 if !still_valid {
290 return Ok(AuthResponse {
291 success: false,
292 message: "Invalid credentials".to_string(),
293 token: None,
294 });
295 }
296
297 tx.execute(
300 "DELETE FROM sessions WHERE id IN (
301 SELECT id FROM sessions
302 WHERE user_id = $1 AND expires_at > NOW()
303 ORDER BY created_at DESC, id DESC
304 OFFSET $2
305 )",
306 &[&user_id, &(max_sessions - 1)],
307 )
308 .await
309 .map_err(AppError::query)?;
310
311 tx.execute(
313 "INSERT INTO sessions (user_id, token_hash, user_agent, expires_at, generation)
314 SELECT id, $2, $3, $4, session_generation FROM users WHERE id = $1",
315 &[&user_id, &token_hash, &None::<String>, &expires_at],
316 )
317 .await
318 .map_err(AppError::query)?;
319
320 tx.commit().await.map_err(AppError::query)?;
321
322 let cookie = session::session_cookie(
323 &token,
324 session::SESSION_MAX_AGE_SECS,
325 crate::api::settings::runtime_security_settings()
326 .await
327 .cookie_secure,
328 );
329 if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
331 crate::request_ctx::append_response_header(SET_COOKIE, value);
332 }
333
334 Ok(AuthResponse {
335 success: true,
336 message: "登录成功".to_string(),
337 token: None,
338 })
339}
340
341#[server(Logout, endpoint = "logout", input = leptos::server_fn::codec::Json)]
346pub async fn logout() -> Result<AuthResponse, ApiError> {
347 let token = get_session_from_ctx();
348
349 let client = get_conn().await.map_err(AppError::db_conn)?;
350
351 let cookie = session::session_cookie(
353 "",
354 0,
355 crate::api::settings::runtime_security_settings()
356 .await
357 .cookie_secure,
358 );
359 if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
360 crate::request_ctx::append_response_header(SET_COOKIE, value);
361 }
362
363 if let Some(t) = token {
364 let token_hash = session::hash_token(&t);
365 crate::cache::invalidate_session_user(&token_hash).await;
366 client
367 .execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash])
368 .await
369 .map_err(AppError::query)?;
370 }
371
372 Ok(AuthResponse {
373 success: true,
374 message: "登出成功".to_string(),
375 token: None,
376 })
377}
378
379#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
380pub struct CurrentUserResponse {
382 pub user: Option<PublicUser>,
384}
385
386#[cfg(feature = "server")]
387pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ApiError> {
394 let token_hash = session::hash_token(token);
395
396 if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
397 let session_state: Option<(i32, String)> = get_conn()
399 .await
400 .map_err(AppError::db_conn)?
401 .query_opt(
402 "SELECT u.session_generation, u.role
403 FROM sessions s
404 JOIN users u ON s.user_id = u.id
405 WHERE s.token_hash = $1 AND s.expires_at > NOW()
406 AND s.generation = u.session_generation",
407 &[&token_hash],
408 )
409 .await
410 .map_err(AppError::query)?
411 .map(|r| (r.get(0), r.get(1)));
412
413 match session_state {
414 Some((generation, role))
415 if generation == cached.session_generation
416 && role == "admin"
417 && matches!(cached.role, UserRole::Admin) =>
418 {
419 return Ok(Some(cached));
420 }
421 _ => {
422 crate::cache::invalidate_session_user(&token_hash).await;
424 return Ok(None);
425 }
426 }
427 }
428
429 let client = get_conn().await.map_err(AppError::db_conn)?;
430
431 let row = client
432 .query_opt(
433 "SELECT u.id, u.username, u.email, u.display_name, u.avatar_url, u.role, u.created_at, u.session_generation
434 FROM sessions s
435 JOIN users u ON s.user_id = u.id
436 WHERE s.token_hash = $1 AND s.expires_at > NOW()
437 AND s.generation = u.session_generation",
438 &[&token_hash],
439 )
440 .await
441 .map_err(AppError::query)?;
442
443 let user = match row {
444 Some(row) => {
445 let role_str: String = row.get("role");
446 let role = match UserRole::from_str(&role_str) {
447 Some(UserRole::Admin) => UserRole::Admin,
448 _ => return Ok(None),
449 };
450 Some(SessionUser {
451 id: row.get("id"),
452 username: row.get("username"),
453 email: row.get("email"),
454 display_name: row.get("display_name"),
455 avatar_url: row.get("avatar_url"),
456 role,
457 created_at: row.get("created_at"),
458 session_generation: row.get("session_generation"),
459 })
460 }
461 None => None,
462 };
463
464 if let Some(ref u) = user {
465 crate::cache::set_session_user(&token_hash, u.clone()).await;
466 }
467
468 Ok(user)
469}
470
471#[server(GetCurrentUser, endpoint = "get_current_user", input = leptos::server_fn::codec::Json)]
475pub async fn get_current_user() -> Result<CurrentUserResponse, ApiError> {
476 let token = match get_session_from_ctx() {
477 Some(t) => t,
478 None => return Ok(CurrentUserResponse { user: None }),
479 };
480
481 let user = get_user_by_token(&token).await?.map(PublicUser::from);
482
483 Ok(CurrentUserResponse { user })
484}
485
486#[cfg(feature = "server")]
487pub async fn get_current_admin_user() -> Result<SessionUser, ApiError> {
491 let token = get_session_from_ctx().ok_or_else(|| ApiError::Unauthorized("未登录".into()))?;
492
493 let session_user = get_user_by_token(&token)
494 .await?
495 .ok_or_else(|| ApiError::Unauthorized("会话已过期".into()))?;
496
497 if session_user.role != UserRole::Admin {
498 return Err(ApiError::Forbidden("权限不足".into()));
499 }
500
501 Ok(session_user)
502}
503
504#[cfg(feature = "server")]
508struct AdminEnvConfig {
509 username: String,
510 email: String,
511 password: String,
512}
513
514#[cfg(feature = "server")]
519fn parse_admin_env() -> Option<AdminEnvConfig> {
520 let username = std::env::var("ADMIN_USERNAME").ok()?;
521 let username = username.trim().to_string();
522 if username.is_empty() {
523 return None;
524 }
525 let email = std::env::var("ADMIN_EMAIL").ok()?;
526 let email = email.trim().to_string();
527 if email.is_empty() {
528 return None;
529 }
530 let password = std::env::var("ADMIN_PASSWORD").ok()?;
531 if password.is_empty() {
532 return None;
533 }
534 Some(AdminEnvConfig {
535 username,
536 email,
537 password,
538 })
539}
540
541#[cfg(feature = "server")]
545pub(crate) fn admin_env_active() -> bool {
546 parse_admin_env().is_some()
547}
548
549#[cfg(feature = "server")]
564pub(crate) async fn sync_admin_from_env(client: &tokio_postgres::Client) -> Result<(), AppError> {
565 let Some(cfg) = parse_admin_env() else {
566 return Ok(());
567 };
568 if let Err(e) = validate_username(&cfg.username) {
569 tracing::warn!(
570 "ADMIN_USERNAME={:?} 非法,跳过初始管理员同步: {e}",
571 cfg.username
572 );
573 return Ok(());
574 }
575 if let Err(e) = validate_email(&cfg.email) {
576 tracing::warn!("ADMIN_EMAIL={:?} 非法,跳过初始管理员同步: {e}", cfg.email);
577 return Ok(());
578 }
579 if let Err(e) = validate_password(&cfg.password) {
580 tracing::warn!("ADMIN_PASSWORD 不合法({e}),跳过初始管理员同步");
581 return Ok(());
582 }
583
584 let pw = cfg.password.clone();
586 let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw))
587 .await
588 .map_err(|_| AppError::Internal("初始管理员密码哈希任务失败"))?
589 .map_err(|_| AppError::Internal("初始管理员密码哈希失败"))?;
590
591 let row = client
592 .query_opt(
593 "SELECT id, email, role, password_hash FROM users WHERE username = $1",
594 &[&cfg.username],
595 )
596 .await
597 .map_err(AppError::query)?;
598
599 let Some(row) = row else {
600 let created = client
602 .query_opt(
603 "INSERT INTO users (username, email, password_hash, role)
604 VALUES ($1, $2, $3, 'admin')
605 ON CONFLICT DO NOTHING
606 RETURNING id",
607 &[&cfg.username, &cfg.email, &password_hash],
608 )
609 .await
610 .map_err(AppError::query)?;
611 if created.is_some() {
612 tracing::info!(
613 "初始管理员已从环境变量创建: username={}(登录成功后可从 .env 移除 ADMIN_* 变量)",
614 cfg.username
615 );
616 } else {
617 tracing::warn!(
618 "无法创建初始管理员 username={}(用户名/邮箱已被占用,或库中已存在其他 admin——全库仅允许一个 admin)。\
619 可将 ADMIN_USERNAME 改为现有 admin 的用户名以同步其凭据",
620 cfg.username
621 );
622 }
623 return Ok(());
624 };
625
626 let user_id: i32 = row.get("id");
628 let stored_hash: String = row.get("password_hash");
629 let stored_email: String = row.get("email");
630 let role: String = row.get("role");
631
632 let pw_for_verify = cfg.password.clone();
634 let same_password = tokio::task::spawn_blocking(move || {
635 password::verify_password(&pw_for_verify, &stored_hash)
636 })
637 .await
638 .map_err(|_| AppError::Internal("初始管理员密码校验任务失败"))?
639 .unwrap_or(false);
640 if !same_password {
641 client
644 .execute(
645 "UPDATE users SET password_hash = $1, session_generation = session_generation + 1
646 WHERE id = $2",
647 &[&password_hash, &user_id],
648 )
649 .await
650 .map_err(AppError::query)?;
651 tracing::info!("初始管理员密码已从环境变量覆盖: username={}", cfg.username);
652 }
653
654 if role != "admin" {
655 client
657 .execute("UPDATE users SET role = 'admin' WHERE id = $1", &[&user_id])
658 .await
659 .map_err(AppError::query)?;
660 tracing::info!("初始管理员角色已恢复为 admin: username={}", cfg.username);
661 }
662
663 if stored_email != cfg.email {
664 let email_taken: bool = client
665 .query_one(
666 "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1 AND id <> $2)",
667 &[&cfg.email, &user_id],
668 )
669 .await
670 .map_err(AppError::query)?
671 .get(0);
672 if email_taken {
673 tracing::warn!(
674 "ADMIN_EMAIL={:?} 已被其他用户占用,跳过邮箱同步(密码/角色不受影响)",
675 cfg.email
676 );
677 } else {
678 client
679 .execute(
680 "UPDATE users SET email = $1 WHERE id = $2",
681 &[&cfg.email, &user_id],
682 )
683 .await
684 .map_err(AppError::query)?;
685 }
686 }
687
688 Ok(())
689}
690
691#[cfg(feature = "server")]
692pub(crate) async fn require_admin_from_headers(
698 headers: &http::HeaderMap,
699) -> Result<SessionUser, (http::StatusCode, &'static str)> {
700 let cookie_header = headers
701 .get(http::header::COOKIE)
702 .and_then(|h| h.to_str().ok())
703 .unwrap_or("");
704 let token = session::parse_session_token(cookie_header)
705 .ok_or((http::StatusCode::UNAUTHORIZED, "未登录"))?;
706 let user = match get_user_by_token(token).await {
707 Ok(Some(user)) => user,
708 _ => return Err((http::StatusCode::UNAUTHORIZED, "会话已过期")),
709 };
710 if user.role != UserRole::Admin {
711 return Err((http::StatusCode::FORBIDDEN, "权限不足"));
712 }
713 Ok(user)
714}
715
716#[cfg(all(test, feature = "server"))]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn validate_username_valid() {
722 assert!(validate_username("admin").is_ok());
723 assert!(validate_username("user_123").is_ok());
724 assert!(validate_username("abc").is_ok());
725 }
726
727 #[test]
728 fn validate_username_too_short() {
729 assert!(validate_username("ab").is_err());
730 }
731
732 #[test]
733 fn validate_username_too_long() {
734 assert!(validate_username(&"a".repeat(51)).is_err());
735 }
736
737 #[test]
738 fn validate_username_max_length() {
739 assert!(validate_username(&"a".repeat(50)).is_ok());
740 }
741
742 #[test]
743 fn validate_username_special_chars() {
744 assert!(validate_username("user name").is_err());
745 assert!(validate_username("user@name").is_err());
746 assert!(validate_username("user-name").is_err());
747 }
748
749 #[test]
750 fn validate_username_unicode() {
751 assert!(validate_username("用户名").is_ok());
752 }
753
754 #[test]
755 fn validate_username_counts_chinese_chars_not_bytes() {
756 assert!(validate_username(&"汉".repeat(20)).is_ok());
758 assert!(validate_username(&"汉".repeat(50)).is_ok());
759 assert!(validate_username(&"汉".repeat(51)).is_err());
760 assert!(validate_username("汉字").is_err());
762 }
763
764 #[test]
765 fn validate_email_valid() {
766 assert!(validate_email("user@example.com").is_ok());
767 assert!(validate_email("a.b+c@domain.co").is_ok());
768 }
769
770 #[test]
771 fn validate_email_invalid() {
772 assert!(validate_email("notanemail").is_err());
773 assert!(validate_email("@domain.com").is_err());
774 assert!(validate_email("user@").is_err());
775 assert!(validate_email("user@.com").is_err());
776 assert!(validate_email("").is_err());
777 }
778
779 #[test]
780 fn validate_password_valid() {
781 assert!(validate_password("12345678").is_ok());
782 assert!(validate_password("a very long password with spaces").is_ok());
783 }
784
785 #[test]
786 fn validate_password_too_short() {
787 assert!(validate_password("1234567").is_err());
788 }
789
790 #[test]
791 fn validate_password_exactly_8() {
792 assert!(validate_password("12345678").is_ok());
793 }
794
795 #[test]
796 fn validate_password_empty() {
797 assert!(validate_password("").is_err());
798 }
799
800 #[test]
801 fn validate_password_too_long() {
802 assert!(validate_password(&"a".repeat(257)).is_err());
803 assert!(validate_password(&"a".repeat(256)).is_ok());
804 }
805
806 fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
808 let saved: Vec<(String, Option<String>)> = vars
809 .iter()
810 .map(|(k, _)| (k.to_string(), std::env::var(k).ok()))
811 .collect();
812 for (k, v) in vars {
813 match v {
814 Some(v) => std::env::set_var(k, v),
815 None => std::env::remove_var(k),
816 }
817 }
818 f();
819 for (k, v) in saved {
820 match v {
821 Some(v) => std::env::set_var(k, v),
822 None => std::env::remove_var(k),
823 }
824 }
825 }
826
827 #[test]
828 #[serial_test::serial]
829 fn parse_admin_env_none_when_all_unset() {
830 with_env(
831 &[
832 ("ADMIN_USERNAME", None),
833 ("ADMIN_EMAIL", None),
834 ("ADMIN_PASSWORD", None),
835 ],
836 || assert!(parse_admin_env().is_none()),
837 );
838 }
839
840 #[test]
841 #[serial_test::serial]
842 fn parse_admin_env_none_when_any_missing() {
843 with_env(
845 &[
846 ("ADMIN_USERNAME", Some("admin")),
847 ("ADMIN_EMAIL", Some("admin@example.com")),
848 ("ADMIN_PASSWORD", None),
849 ],
850 || assert!(parse_admin_env().is_none()),
851 );
852 with_env(
854 &[
855 ("ADMIN_USERNAME", Some("admin")),
856 ("ADMIN_EMAIL", None),
857 ("ADMIN_PASSWORD", Some("strongpass123")),
858 ],
859 || assert!(parse_admin_env().is_none()),
860 );
861 with_env(
863 &[
864 ("ADMIN_USERNAME", None),
865 ("ADMIN_EMAIL", Some("admin@example.com")),
866 ("ADMIN_PASSWORD", Some("strongpass123")),
867 ],
868 || assert!(parse_admin_env().is_none()),
869 );
870 }
871
872 #[test]
873 #[serial_test::serial]
874 fn parse_admin_env_none_on_empty_values() {
875 with_env(
876 &[
877 ("ADMIN_USERNAME", Some("admin")),
878 ("ADMIN_EMAIL", Some("admin@example.com")),
879 ("ADMIN_PASSWORD", Some("")),
880 ],
881 || assert!(parse_admin_env().is_none()),
882 );
883 with_env(
884 &[
885 ("ADMIN_USERNAME", Some(" ")),
886 ("ADMIN_EMAIL", Some("admin@example.com")),
887 ("ADMIN_PASSWORD", Some("strongpass123")),
888 ],
889 || assert!(parse_admin_env().is_none()),
890 );
891 }
892
893 #[test]
894 #[serial_test::serial]
895 fn parse_admin_env_some_when_all_set() {
896 with_env(
897 &[
898 ("ADMIN_USERNAME", Some(" admin ")),
899 ("ADMIN_EMAIL", Some(" admin@example.com ")),
900 ("ADMIN_PASSWORD", Some(" strong pass 123 ")),
901 ],
902 || {
903 let cfg = parse_admin_env().expect("三个变量齐全应返回 Some");
904 assert_eq!(cfg.username, "admin");
906 assert_eq!(cfg.email, "admin@example.com");
907 assert_eq!(cfg.password, " strong pass 123 ");
908 },
909 );
910 }
911
912 #[tokio::test]
913 async fn require_admin_from_headers_rejects_missing_session_cookie() {
914 let mut headers = http::HeaderMap::new();
915 assert_eq!(
916 require_admin_from_headers(&headers).await.unwrap_err(),
917 (http::StatusCode::UNAUTHORIZED, "未登录")
918 );
919
920 headers.insert(http::header::COOKIE, HeaderValue::from_static("theme=dark"));
921 assert_eq!(
922 require_admin_from_headers(&headers).await.unwrap_err(),
923 (http::StatusCode::UNAUTHORIZED, "未登录")
924 );
925 }
926}