Skip to main content

heldar_kernel/routes/
auth.rs

1//! Stage 4 auth + user/API-key administration.
2//!
3//! `/auth/login` exchanges username+password for a bearer session token; `/auth/logout` revokes it;
4//! `/auth/me` reports the caller. `/users` and `/api-keys` are admin-only management surfaces. All
5//! mutations are written to the immutable audit log.
6
7use 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    // Always run argon2 verification (against a dummy hash when the user is missing/disabled) so
51    // login latency does not reveal whether an account exists. The error is uniform too.
52    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    // Set the session as an HttpOnly cookie (browser auth: not JS-readable, so XSS can't exfiltrate
73    // it; the media plane gets it automatically since the SPA is same-origin). The token is still in
74    // the body for non-browser clients; browsers should ignore it and rely on the cookie.
75    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    // Clear the session cookie regardless (idempotent logout).
89    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    // Lockout guard, ATOMIC: when this change demotes/disables an admin, the UPDATE only applies if
205    // ANOTHER active admin still exists at write time. SQLite serializes writers, so two concurrent
206    // demotions of different admins cannot both succeed — the second finds the EXISTS false and is
207    // rejected, always leaving an admin standing. (A separate COUNT-then-UPDATE would race.)
208    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    // Revoke sessions if the account was disabled.
244    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    // Atomic last-admin guard (see update_user): the conditional DELETE removes an admin only if
283    // another active admin still exists, so concurrent deletes cannot drain the admins to zero.
284    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    // The full key is returned exactly once; only its hash is stored.
360    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    /// Build a minimal in-memory AppState (single connection so the :memory: DB persists across
400    /// queries) with real migrations applied, mirroring the helper used by the other route tests.
401    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        // System admin (auth-disabled implicit principal) reports role=admin, kind=system.
437        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        // A user-kind principal maps to kind=user and echoes its role.
444        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        // Empty (whitespace-only) username.
454        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        // Password shorter than MIN_PASSWORD_LEN.
474        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        // Unrecognized role.
494        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        // Surrounding whitespace is trimmed; role defaults to viewer; active defaults to true.
519        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        // list_users is ordered by username ASC.
552        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        // system_admin has id "system"; deleting that same id hits the self-deletion guard before
586        // any existence check.
587        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        // The only admin in the table.
603        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        // Demoting the last active admin is refused.
618        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    /// AppState around a caller-provided pool — for concurrency tests needing a shared,
634    /// multi-connection DB (the single-connection in-memory `test_state` would serialize the race).
635    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        // Temp-FILE DB so the pool's connections see each other's committed writes — the
656        // single-connection in-memory pool used elsewhere would serialize and hide the race.
657        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        // Exactly two active admins.
670        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        // Demote BOTH admins at once. Old check-then-act: both pass -> zero admins. Atomic guard:
695        // at least one is rejected, an admin always remains.
696        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        // Empty name is rejected.
736        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        // Valid creation: role defaults to integration, the secret is prefixed and returned once.
750        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        // No users yet -> unknown user is uniformly Unauthorized.
772        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        // Seed an operator.
785        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        // Wrong password for an existing user is also Unauthorized.
800        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        // Correct credentials succeed: 200, an HttpOnly session cookie, and one persisted session.
813        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        // No credentials present -> still a clean, idempotent logout.
844        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}