1use axum::extract::{Path, State};
8use axum::http::{header, HeaderMap, StatusCode};
9use axum::response::{AppendHeaders, IntoResponse};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use chrono::Utc;
13use serde_json::{json, Value};
14use uuid::Uuid;
15
16use crate::auth::{self, Principal, Role};
17use crate::error::{AppError, AppResult};
18use crate::models::{
19 ApiKey, ApiKeyCreate, ApiKeyView, LoginRequest, User, UserCreate, UserUpdate, UserView,
20};
21use crate::state::AppState;
22
23pub fn router() -> Router<AppState> {
24 Router::new()
25 .route("/api/v1/auth/login", post(login))
26 .route("/api/v1/auth/logout", post(logout))
27 .route("/api/v1/auth/me", get(me))
28 .route("/api/v1/users", get(list_users).post(create_user))
29 .route(
30 "/api/v1/users/{id}",
31 axum::routing::patch(update_user).delete(delete_user),
32 )
33 .route("/api/v1/api-keys", get(list_api_keys).post(create_api_key))
34 .route(
35 "/api/v1/api-keys/{id}",
36 axum::routing::delete(delete_api_key),
37 )
38}
39
40const MIN_PASSWORD_LEN: usize = 8;
41
42async fn login(
43 State(st): State<AppState>,
44 Json(body): Json<LoginRequest>,
45) -> AppResult<impl IntoResponse> {
46 let candidate = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
47 .bind(body.username.trim())
48 .fetch_optional(&st.pool)
49 .await?;
50 let phc = candidate
53 .as_ref()
54 .map(|u| u.password_hash.as_str())
55 .unwrap_or_else(|| auth::dummy_password_hash());
56 let password_ok = auth::verify_password(&body.password, phc);
57 let user = match candidate {
58 Some(u) if u.active && password_ok => u,
59 _ => return Err(AppError::Unauthorized("invalid credentials".into())),
60 };
61 let (token, expires_at) = auth::issue_session(&st.pool, &st.cfg, &user.id).await?;
62 let principal = Principal {
63 id: user.id.clone(),
64 name: user
65 .display_name
66 .clone()
67 .unwrap_or_else(|| user.username.clone()),
68 role: Role::parse(&user.role).unwrap_or(Role::Viewer),
69 kind: crate::auth::PrincipalKind::User,
70 };
71 auth::audit(&st.pool, &principal, "login", "user", &user.id, json!({})).await;
72 let cookie = auth::session_cookie(&token, &st.cfg);
76 let body = Json(json!({
77 "token": token,
78 "expires_at": expires_at,
79 "user": UserView::from(user),
80 }));
81 Ok((AppendHeaders([(header::SET_COOKIE, cookie)]), body))
82}
83
84async fn logout(State(st): State<AppState>, headers: HeaderMap) -> AppResult<impl IntoResponse> {
85 if let Some(tok) = auth::token_from_headers(&headers) {
86 auth::revoke_session(&st.pool, &tok).await?;
87 }
88 let cookie = auth::clear_session_cookie(&st.cfg);
90 Ok((
91 StatusCode::NO_CONTENT,
92 AppendHeaders([(header::SET_COOKIE, cookie)]),
93 ))
94}
95
96async fn me(principal: Principal) -> AppResult<Json<Value>> {
97 Ok(Json(json!({
98 "id": principal.id,
99 "name": principal.name,
100 "role": principal.role.as_str(),
101 "kind": match principal.kind {
102 crate::auth::PrincipalKind::User => "user",
103 crate::auth::PrincipalKind::ApiKey => "api_key",
104 crate::auth::PrincipalKind::System => "system",
105 },
106 })))
107}
108
109async fn list_users(
110 State(st): State<AppState>,
111 principal: Principal,
112) -> AppResult<Json<Vec<UserView>>> {
113 principal.require(principal.can_admin(), "manage users")?;
114 let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY username ASC")
115 .fetch_all(&st.pool)
116 .await?;
117 Ok(Json(users.into_iter().map(UserView::from).collect()))
118}
119
120async fn create_user(
121 State(st): State<AppState>,
122 principal: Principal,
123 Json(body): Json<UserCreate>,
124) -> AppResult<(StatusCode, Json<UserView>)> {
125 principal.require(principal.can_admin(), "create users")?;
126 let username = body.username.trim();
127 if username.is_empty() {
128 return Err(AppError::BadRequest("`username` is required".into()));
129 }
130 if body.password.len() < MIN_PASSWORD_LEN {
131 return Err(AppError::BadRequest(format!(
132 "`password` must be at least {MIN_PASSWORD_LEN} characters"
133 )));
134 }
135 let role = body.role.as_deref().unwrap_or("viewer");
136 if !Role::is_valid(role) {
137 return Err(AppError::BadRequest(
138 "`role` must be admin|manager|guard|viewer|integration".into(),
139 ));
140 }
141 let hash = auth::hash_password(&body.password)?;
142 let id = format!("usr_{}", Uuid::new_v4().simple());
143 let now = Utc::now();
144 sqlx::query(
145 "INSERT INTO users (id, username, password_hash, role, display_name, active, created_at, updated_at)
146 VALUES (?,?,?,?,?,?,?,?)",
147 )
148 .bind(&id)
149 .bind(username)
150 .bind(hash)
151 .bind(role)
152 .bind(&body.display_name)
153 .bind(body.active.unwrap_or(true))
154 .bind(now)
155 .bind(now)
156 .execute(&st.pool)
157 .await?;
158 auth::audit(
159 &st.pool,
160 &principal,
161 "create_user",
162 "user",
163 &id,
164 json!({ "role": role }),
165 )
166 .await;
167 let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
168 .bind(&id)
169 .fetch_one(&st.pool)
170 .await?;
171 Ok((StatusCode::CREATED, Json(UserView::from(user))))
172}
173
174async fn update_user(
175 State(st): State<AppState>,
176 principal: Principal,
177 Path(id): Path<String>,
178 Json(body): Json<UserUpdate>,
179) -> AppResult<Json<UserView>> {
180 principal.require(principal.can_admin(), "modify users")?;
181 let cur = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
182 .bind(&id)
183 .fetch_optional(&st.pool)
184 .await?
185 .ok_or_else(|| AppError::NotFound(format!("user {id} not found")))?;
186
187 let role = body.role.unwrap_or_else(|| cur.role.clone());
188 if !Role::is_valid(&role) {
189 return Err(AppError::BadRequest(
190 "`role` must be admin|manager|guard|viewer|integration".into(),
191 ));
192 }
193 let active = body.active.unwrap_or(cur.active);
194 let display_name = body.display_name.or(cur.display_name);
195 let password_hash = match body.password {
196 Some(p) if p.len() >= MIN_PASSWORD_LEN => auth::hash_password(&p)?,
197 Some(_) => {
198 return Err(AppError::BadRequest(format!(
199 "`password` must be at least {MIN_PASSWORD_LEN} characters"
200 )))
201 }
202 None => cur.password_hash,
203 };
204 let demoting_admin = cur.role == "admin" && (role != "admin" || !active);
209 let affected = if demoting_admin {
210 sqlx::query(
211 "UPDATE users SET password_hash=?, role=?, display_name=?, active=?, updated_at=? \
212 WHERE id=? AND EXISTS (SELECT 1 FROM users WHERE role='admin' AND active=1 AND id != ?)",
213 )
214 .bind(&password_hash)
215 .bind(&role)
216 .bind(&display_name)
217 .bind(active)
218 .bind(Utc::now())
219 .bind(&id)
220 .bind(&id)
221 .execute(&st.pool)
222 .await?
223 .rows_affected()
224 } else {
225 sqlx::query(
226 "UPDATE users SET password_hash=?, role=?, display_name=?, active=?, updated_at=? WHERE id=?",
227 )
228 .bind(&password_hash)
229 .bind(&role)
230 .bind(&display_name)
231 .bind(active)
232 .bind(Utc::now())
233 .bind(&id)
234 .execute(&st.pool)
235 .await?
236 .rows_affected()
237 };
238 if demoting_admin && affected == 0 {
239 return Err(AppError::BadRequest(
240 "cannot demote or disable the last active admin".into(),
241 ));
242 }
243 if !active {
245 let _ = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
246 .bind(&id)
247 .execute(&st.pool)
248 .await;
249 }
250 auth::audit(
251 &st.pool,
252 &principal,
253 "update_user",
254 "user",
255 &id,
256 json!({ "role": role, "active": active }),
257 )
258 .await;
259 let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
260 .bind(&id)
261 .fetch_one(&st.pool)
262 .await?;
263 Ok(Json(UserView::from(user)))
264}
265
266async fn delete_user(
267 State(st): State<AppState>,
268 principal: Principal,
269 Path(id): Path<String>,
270) -> AppResult<StatusCode> {
271 principal.require(principal.can_admin(), "delete users")?;
272 if principal.id == id {
273 return Err(AppError::BadRequest(
274 "cannot delete your own account".into(),
275 ));
276 }
277 let cur = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
278 .bind(&id)
279 .fetch_optional(&st.pool)
280 .await?
281 .ok_or_else(|| AppError::NotFound(format!("user {id} not found")))?;
282 let affected = if cur.role == "admin" {
285 sqlx::query(
286 "DELETE FROM users WHERE id = ? AND EXISTS (SELECT 1 FROM users WHERE role='admin' AND active=1 AND id != ?)",
287 )
288 .bind(&id)
289 .bind(&id)
290 .execute(&st.pool)
291 .await?
292 .rows_affected()
293 } else {
294 sqlx::query("DELETE FROM users WHERE id = ?")
295 .bind(&id)
296 .execute(&st.pool)
297 .await?
298 .rows_affected()
299 };
300 if cur.role == "admin" && affected == 0 {
301 return Err(AppError::BadRequest(
302 "cannot delete the last active admin".into(),
303 ));
304 }
305 auth::audit(&st.pool, &principal, "delete_user", "user", &id, json!({})).await;
306 Ok(StatusCode::NO_CONTENT)
307}
308
309async fn list_api_keys(
310 State(st): State<AppState>,
311 principal: Principal,
312) -> AppResult<Json<Vec<ApiKeyView>>> {
313 principal.require(principal.can_admin(), "manage API keys")?;
314 let keys = sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys ORDER BY created_at DESC")
315 .fetch_all(&st.pool)
316 .await?;
317 Ok(Json(keys.into_iter().map(ApiKeyView::from).collect()))
318}
319
320async fn create_api_key(
321 State(st): State<AppState>,
322 principal: Principal,
323 Json(body): Json<ApiKeyCreate>,
324) -> AppResult<(StatusCode, Json<Value>)> {
325 principal.require(principal.can_admin(), "create API keys")?;
326 if body.name.trim().is_empty() {
327 return Err(AppError::BadRequest("`name` is required".into()));
328 }
329 let role = body.role.as_deref().unwrap_or("integration");
330 if !Role::is_valid(role) {
331 return Err(AppError::BadRequest(
332 "`role` must be admin|manager|guard|viewer|integration".into(),
333 ));
334 }
335 let key = auth::random_token(auth::APIKEY_PREFIX);
336 let prefix: String = key.chars().take(12).collect();
337 let id = format!("key_{}", Uuid::new_v4().simple());
338 sqlx::query(
339 "INSERT INTO api_keys (id, name, key_hash, key_prefix, role, active, created_at)
340 VALUES (?,?,?,?,?,1,?)",
341 )
342 .bind(&id)
343 .bind(body.name.trim())
344 .bind(auth::token_hash(&key))
345 .bind(&prefix)
346 .bind(role)
347 .bind(Utc::now())
348 .execute(&st.pool)
349 .await?;
350 auth::audit(
351 &st.pool,
352 &principal,
353 "create_api_key",
354 "api_key",
355 &id,
356 json!({ "role": role }),
357 )
358 .await;
359 Ok((
361 StatusCode::CREATED,
362 Json(json!({ "id": id, "name": body.name.trim(), "role": role, "key": key })),
363 ))
364}
365
366async fn delete_api_key(
367 State(st): State<AppState>,
368 principal: Principal,
369 Path(id): Path<String>,
370) -> AppResult<StatusCode> {
371 principal.require(principal.can_admin(), "delete API keys")?;
372 let res = sqlx::query("DELETE FROM api_keys WHERE id = ?")
373 .bind(&id)
374 .execute(&st.pool)
375 .await?;
376 if res.rows_affected() == 0 {
377 return Err(AppError::NotFound(format!("api key {id} not found")));
378 }
379 auth::audit(
380 &st.pool,
381 &principal,
382 "delete_api_key",
383 "api_key",
384 &id,
385 json!({}),
386 )
387 .await;
388 Ok(StatusCode::NO_CONTENT)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::config::Config;
395 use crate::services::recorder::RecorderManager;
396 use crate::services::sampler::SamplerManager;
397 use std::sync::Arc;
398
399 async fn test_state(auth_enabled: bool) -> AppState {
402 let pool = sqlx::sqlite::SqlitePoolOptions::new()
403 .max_connections(1)
404 .connect("sqlite::memory:")
405 .await
406 .unwrap();
407 crate::db::run_migrations(&pool).await.unwrap();
408 let mut cfg = Config::from_env();
409 cfg.auth_enabled = auth_enabled;
410 let cfg = Arc::new(cfg);
411 AppState {
412 recorder: RecorderManager::new(pool.clone(), cfg.clone()),
413 sampler: SamplerManager::new(pool.clone(), cfg.clone()),
414 mirror: None,
415 consumers: Arc::new(Vec::new()),
416 modules: Arc::new(Vec::new()),
417 catalog: Arc::new(crate::services::registry::CatalogService::new(&cfg)),
418 http: reqwest::Client::new(),
419 started_at: chrono::Utc::now(),
420 pool,
421 cfg,
422 }
423 }
424
425 fn viewer() -> Principal {
426 Principal {
427 id: "usr_viewer".into(),
428 name: "vee".into(),
429 role: Role::Viewer,
430 kind: auth::PrincipalKind::User,
431 }
432 }
433
434 #[tokio::test]
435 async fn me_reports_principal_role_and_kind() {
436 let Json(v) = me(Principal::system_admin()).await.unwrap();
438 assert_eq!(v["id"], "system");
439 assert_eq!(v["name"], "system");
440 assert_eq!(v["role"], "admin");
441 assert_eq!(v["kind"], "system");
442
443 let Json(v) = me(viewer()).await.unwrap();
445 assert_eq!(v["role"], "viewer");
446 assert_eq!(v["kind"], "user");
447 }
448
449 #[tokio::test]
450 async fn create_user_validation_rejects_bad_input() {
451 let st = test_state(false).await;
452
453 let err = create_user(
455 State(st.clone()),
456 Principal::system_admin(),
457 Json(UserCreate {
458 username: " ".into(),
459 password: "x".repeat(MIN_PASSWORD_LEN),
460 role: None,
461 display_name: None,
462 active: None,
463 }),
464 )
465 .await
466 .err()
467 .unwrap();
468 match err {
469 AppError::BadRequest(m) => assert!(m.contains("username")),
470 other => panic!("expected BadRequest, got {other:?}"),
471 }
472
473 let err = create_user(
475 State(st.clone()),
476 Principal::system_admin(),
477 Json(UserCreate {
478 username: "joe".into(),
479 password: "x".repeat(MIN_PASSWORD_LEN - 1),
480 role: None,
481 display_name: None,
482 active: None,
483 }),
484 )
485 .await
486 .err()
487 .unwrap();
488 match err {
489 AppError::BadRequest(m) => assert!(m.contains("password")),
490 other => panic!("expected BadRequest, got {other:?}"),
491 }
492
493 let err = create_user(
495 State(st.clone()),
496 Principal::system_admin(),
497 Json(UserCreate {
498 username: "joe".into(),
499 password: "x".repeat(MIN_PASSWORD_LEN),
500 role: Some("superuser".into()),
501 display_name: None,
502 active: None,
503 }),
504 )
505 .await
506 .err()
507 .unwrap();
508 match err {
509 AppError::BadRequest(m) => assert!(m.contains("role")),
510 other => panic!("expected BadRequest, got {other:?}"),
511 }
512 }
513
514 #[tokio::test]
515 async fn create_user_defaults_and_list_orders() {
516 let st = test_state(false).await;
517
518 let (status, Json(uv)) = create_user(
520 State(st.clone()),
521 Principal::system_admin(),
522 Json(UserCreate {
523 username: " bravo ".into(),
524 password: "x".repeat(MIN_PASSWORD_LEN),
525 role: None,
526 display_name: None,
527 active: None,
528 }),
529 )
530 .await
531 .unwrap();
532 assert_eq!(status, StatusCode::CREATED);
533 assert_eq!(uv.username, "bravo");
534 assert_eq!(uv.role, "viewer");
535 assert!(uv.active);
536
537 let _ = create_user(
538 State(st.clone()),
539 Principal::system_admin(),
540 Json(UserCreate {
541 username: "alpha".into(),
542 password: "x".repeat(MIN_PASSWORD_LEN),
543 role: Some("manager".into()),
544 display_name: Some("Al".into()),
545 active: None,
546 }),
547 )
548 .await
549 .unwrap();
550
551 let Json(users) = list_users(State(st.clone()), Principal::system_admin())
553 .await
554 .unwrap();
555 assert_eq!(users.len(), 2);
556 assert_eq!(users[0].username, "alpha");
557 assert_eq!(users[1].username, "bravo");
558 assert_eq!(users[0].role, "manager");
559 }
560
561 #[tokio::test]
562 async fn non_admin_is_forbidden() {
563 let st = test_state(false).await;
564
565 let err = list_users(State(st.clone()), viewer()).await.err().unwrap();
566 assert!(matches!(err, AppError::Forbidden(_)));
567
568 let err = create_api_key(
569 State(st.clone()),
570 viewer(),
571 Json(ApiKeyCreate {
572 name: "k".into(),
573 role: None,
574 }),
575 )
576 .await
577 .err()
578 .unwrap();
579 assert!(matches!(err, AppError::Forbidden(_)));
580 }
581
582 #[tokio::test]
583 async fn delete_user_rejects_self() {
584 let st = test_state(false).await;
585 let err = delete_user(
588 State(st.clone()),
589 Principal::system_admin(),
590 Path("system".to_string()),
591 )
592 .await
593 .err()
594 .unwrap();
595 assert!(matches!(err, AppError::BadRequest(_)));
596 }
597
598 #[tokio::test]
599 async fn update_user_protects_last_admin() {
600 let st = test_state(false).await;
601
602 let (_, Json(admin)) = create_user(
604 State(st.clone()),
605 Principal::system_admin(),
606 Json(UserCreate {
607 username: "rootadmin".into(),
608 password: "x".repeat(MIN_PASSWORD_LEN),
609 role: Some("admin".into()),
610 display_name: None,
611 active: None,
612 }),
613 )
614 .await
615 .unwrap();
616
617 let err = update_user(
619 State(st.clone()),
620 Principal::system_admin(),
621 Path(admin.id.clone()),
622 Json(UserUpdate {
623 role: Some("viewer".into()),
624 ..Default::default()
625 }),
626 )
627 .await
628 .err()
629 .unwrap();
630 assert!(matches!(err, AppError::BadRequest(_)));
631 }
632
633 async fn state_with_pool(pool: sqlx::SqlitePool) -> AppState {
636 let mut cfg = Config::from_env();
637 cfg.auth_enabled = false;
638 let cfg = std::sync::Arc::new(cfg);
639 AppState {
640 recorder: RecorderManager::new(pool.clone(), cfg.clone()),
641 sampler: SamplerManager::new(pool.clone(), cfg.clone()),
642 mirror: None,
643 consumers: std::sync::Arc::new(Vec::new()),
644 modules: std::sync::Arc::new(Vec::new()),
645 catalog: std::sync::Arc::new(crate::services::registry::CatalogService::new(&cfg)),
646 http: reqwest::Client::new(),
647 started_at: chrono::Utc::now(),
648 pool,
649 cfg,
650 }
651 }
652
653 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
654 async fn concurrent_demotion_cannot_drain_the_last_admin() {
655 let dbpath =
658 std::env::temp_dir().join(format!("heldar-authrace-{}.db", std::process::id()));
659 let _ = std::fs::remove_file(&dbpath);
660 let url = format!("sqlite://{}?mode=rwc", dbpath.display());
661 let pool = sqlx::sqlite::SqlitePoolOptions::new()
662 .max_connections(4)
663 .connect(&url)
664 .await
665 .unwrap();
666 crate::db::run_migrations(&pool).await.unwrap();
667 let st = state_with_pool(pool.clone()).await;
668
669 let mut ids = Vec::new();
671 for u in ["admin_a", "admin_b"] {
672 let (_, Json(v)) = create_user(
673 State(st.clone()),
674 Principal::system_admin(),
675 Json(UserCreate {
676 username: u.into(),
677 password: "x".repeat(MIN_PASSWORD_LEN),
678 role: Some("admin".into()),
679 display_name: None,
680 active: None,
681 }),
682 )
683 .await
684 .unwrap();
685 ids.push(v.id);
686 }
687
688 let demote = || {
689 Json(UserUpdate {
690 role: Some("viewer".into()),
691 ..Default::default()
692 })
693 };
694 let (r1, r2) = tokio::join!(
697 update_user(
698 State(st.clone()),
699 Principal::system_admin(),
700 Path(ids[0].clone()),
701 demote(),
702 ),
703 update_user(
704 State(st.clone()),
705 Principal::system_admin(),
706 Path(ids[1].clone()),
707 demote(),
708 ),
709 );
710
711 let rejected = [r1.is_err(), r2.is_err()]
712 .into_iter()
713 .filter(|e| *e)
714 .count();
715 let remaining: i64 =
716 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1")
717 .fetch_one(&pool)
718 .await
719 .unwrap();
720 let _ = std::fs::remove_file(&dbpath);
721 assert!(
722 remaining >= 1,
723 "LOCKOUT: concurrent demotions drained all active admins (remaining={remaining})"
724 );
725 assert!(
726 rejected >= 1,
727 "at least one of two concurrent last-admin demotions must be rejected"
728 );
729 }
730
731 #[tokio::test]
732 async fn create_api_key_shape_and_validation() {
733 let st = test_state(false).await;
734
735 let err = create_api_key(
737 State(st.clone()),
738 Principal::system_admin(),
739 Json(ApiKeyCreate {
740 name: " ".into(),
741 role: None,
742 }),
743 )
744 .await
745 .err()
746 .unwrap();
747 assert!(matches!(err, AppError::BadRequest(_)));
748
749 let (status, Json(v)) = create_api_key(
751 State(st.clone()),
752 Principal::system_admin(),
753 Json(ApiKeyCreate {
754 name: " cam-bridge ".into(),
755 role: None,
756 }),
757 )
758 .await
759 .unwrap();
760 assert_eq!(status, StatusCode::CREATED);
761 assert_eq!(v["name"], "cam-bridge");
762 assert_eq!(v["role"], "integration");
763 let key = v["key"].as_str().unwrap();
764 assert!(key.starts_with(auth::APIKEY_PREFIX));
765 }
766
767 #[tokio::test]
768 async fn login_unknown_wrong_then_success() {
769 let st = test_state(false).await;
770
771 let err = login(
773 State(st.clone()),
774 Json(LoginRequest {
775 username: "ghost".into(),
776 password: "whatever1".into(),
777 }),
778 )
779 .await
780 .err()
781 .unwrap();
782 assert!(matches!(err, AppError::Unauthorized(_)));
783
784 let _ = create_user(
786 State(st.clone()),
787 Principal::system_admin(),
788 Json(UserCreate {
789 username: "operator".into(),
790 password: "operator-pass".into(),
791 role: Some("manager".into()),
792 display_name: None,
793 active: None,
794 }),
795 )
796 .await
797 .unwrap();
798
799 let err = login(
801 State(st.clone()),
802 Json(LoginRequest {
803 username: "operator".into(),
804 password: "not-the-pass".into(),
805 }),
806 )
807 .await
808 .err()
809 .unwrap();
810 assert!(matches!(err, AppError::Unauthorized(_)));
811
812 let resp = login(
814 State(st.clone()),
815 Json(LoginRequest {
816 username: "operator".into(),
817 password: "operator-pass".into(),
818 }),
819 )
820 .await
821 .unwrap()
822 .into_response();
823 assert_eq!(resp.status(), StatusCode::OK);
824 let set_cookie = resp
825 .headers()
826 .get(header::SET_COOKIE)
827 .unwrap()
828 .to_str()
829 .unwrap();
830 assert!(set_cookie.contains(auth::SESSION_COOKIE));
831 assert!(set_cookie.contains("HttpOnly"));
832
833 let sessions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
834 .fetch_one(&st.pool)
835 .await
836 .unwrap();
837 assert_eq!(sessions, 1);
838 }
839
840 #[tokio::test]
841 async fn logout_is_no_content_and_clears_cookie() {
842 let st = test_state(false).await;
843 let resp = logout(State(st.clone()), HeaderMap::new())
845 .await
846 .unwrap()
847 .into_response();
848 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
849 let set_cookie = resp
850 .headers()
851 .get(header::SET_COOKIE)
852 .unwrap()
853 .to_str()
854 .unwrap();
855 assert!(set_cookie.contains(auth::SESSION_COOKIE));
856 assert!(set_cookie.contains("Max-Age=0"));
857 }
858}