Skip to main content

kellnr_web_ui/
user.rs

1use axum::Json;
2use axum::extract::{Path, State};
3use axum::http::StatusCode;
4use axum_extra::extract::PrivateCookieJar;
5use axum_extra::extract::cookie::Cookie;
6use kellnr_appstate::{AppState, DbState, TokenCacheState};
7use kellnr_auth::token;
8use kellnr_common::util::generate_rand_string;
9use kellnr_db::password::generate_salt;
10use kellnr_db::{self, AuthToken, User};
11use kellnr_settings::constants::{COOKIE_SESSION_ID, COOKIE_SESSION_USER};
12use serde::{Deserialize, Serialize};
13use utoipa::ToSchema;
14
15use crate::error::RouteError;
16use crate::session::{AdminUser, MaybeUser, create_session_jar};
17
18#[derive(Serialize, ToSchema)]
19pub struct NewTokenResponse {
20    name: String,
21    token: String,
22}
23
24/// Add a new auth token for the current user
25#[utoipa::path(
26    post,
27    path = "/me/tokens",
28    tag = "users",
29    request_body = token::NewTokenReqData,
30    responses(
31        (status = 200, description = "Token created successfully", body = NewTokenResponse),
32        (status = 401, description = "Not authenticated")
33    ),
34    security(("session_cookie" = []))
35)]
36pub async fn add_token(
37    user: MaybeUser,
38    State(db): DbState,
39    State(cache): TokenCacheState,
40    Json(auth_token): Json<token::NewTokenReqData>,
41) -> Result<Json<NewTokenResponse>, RouteError> {
42    let token = token::generate_token();
43    db.add_auth_token(&auth_token.name, &token, user.name())
44        .await?;
45
46    cache.invalidate_all();
47
48    Ok(NewTokenResponse {
49        name: auth_token.name.clone(),
50        token,
51    }
52    .into())
53}
54
55/// List auth tokens for the current user
56#[utoipa::path(
57    get,
58    path = "/me/tokens",
59    tag = "users",
60    responses(
61        (status = 200, description = "List of auth tokens", body = Vec<AuthToken>),
62        (status = 401, description = "Not authenticated")
63    ),
64    security(("session_cookie" = []))
65)]
66pub async fn list_tokens(
67    user: MaybeUser,
68    State(db): DbState,
69) -> Result<Json<Vec<AuthToken>>, RouteError> {
70    Ok(Json(db.get_auth_tokens(user.name()).await?))
71}
72
73/// List all users (admin only)
74#[utoipa::path(
75    get,
76    path = "/",
77    tag = "users",
78    responses(
79        (status = 200, description = "List of all users", body = Vec<User>),
80        (status = 403, description = "Admin access required")
81    ),
82    security(("session_cookie" = []))
83)]
84pub async fn list_users(
85    _user: AdminUser,
86    State(db): DbState,
87) -> Result<Json<Vec<User>>, RouteError> {
88    Ok(Json(db.get_users().await?))
89}
90
91/// Delete an auth token
92#[utoipa::path(
93    delete,
94    path = "/me/tokens/{id}",
95    tag = "users",
96    params(
97        ("id" = i32, Path, description = "Token ID to delete")
98    ),
99    responses(
100        (status = 200, description = "Token deleted successfully"),
101        (status = 400, description = "Token not found"),
102        (status = 401, description = "Not authenticated")
103    ),
104    security(("session_cookie" = []))
105)]
106pub async fn delete_token(
107    user: MaybeUser,
108    Path(id): Path<i32>,
109    State(db): DbState,
110    State(cache): TokenCacheState,
111) -> Result<(), RouteError> {
112    db.get_auth_tokens(user.name())
113        .await?
114        .iter()
115        .find(|t| t.id == id)
116        .ok_or_else(|| RouteError::Status(StatusCode::BAD_REQUEST))?;
117
118    db.delete_auth_token(id).await?;
119
120    cache.invalidate_all();
121
122    Ok(())
123}
124
125#[derive(Serialize, ToSchema)]
126pub struct ResetPwd {
127    new_pwd: String,
128    user: String,
129}
130
131/// Reset a user's password (admin only)
132#[utoipa::path(
133    put,
134    path = "/{name}/password",
135    tag = "users",
136    params(
137        ("name" = String, Path, description = "Username")
138    ),
139    responses(
140        (status = 200, description = "Password reset successfully", body = ResetPwd),
141        (status = 403, description = "Admin access required")
142    ),
143    security(("session_cookie" = []))
144)]
145pub async fn reset_pwd(
146    user: AdminUser,
147    Path(name): Path<String>,
148    State(db): DbState,
149) -> Result<Json<ResetPwd>, RouteError> {
150    let new_pwd = generate_rand_string(12);
151    db.change_pwd(&name, &new_pwd).await?;
152
153    Ok(ResetPwd {
154        user: user.name().to_owned(),
155        new_pwd,
156    }
157    .into())
158}
159
160#[derive(Deserialize, ToSchema)]
161pub struct ReadOnlyState {
162    pub state: bool,
163}
164
165/// Change a user's read-only state (admin only)
166#[utoipa::path(
167    post,
168    path = "/{name}/read-only",
169    tag = "users",
170    params(
171        ("name" = String, Path, description = "Username")
172    ),
173    request_body = ReadOnlyState,
174    responses(
175        (status = 200, description = "Read-only state changed successfully"),
176        (status = 400, description = "Cannot lock yourself"),
177        (status = 403, description = "Admin access required")
178    ),
179    security(("session_cookie" = []))
180)]
181pub async fn read_only(
182    user: AdminUser,
183    Path(name): Path<String>,
184    State(db): DbState,
185    State(cache): TokenCacheState,
186    Json(ro_state): Json<ReadOnlyState>,
187) -> Result<(), RouteError> {
188    // Prevent self-locking to avoid lockout
189    if user.name() == name && ro_state.state {
190        return Err(RouteError::Status(StatusCode::BAD_REQUEST));
191    }
192
193    db.change_read_only_state(&name, ro_state.state).await?;
194
195    cache.invalidate_all();
196
197    Ok(())
198}
199
200#[derive(Deserialize, ToSchema)]
201pub struct AdminState {
202    pub state: bool,
203}
204
205/// Change a user's admin state (admin only)
206#[utoipa::path(
207    post,
208    path = "/{name}/admin",
209    tag = "users",
210    params(
211        ("name" = String, Path, description = "Username")
212    ),
213    request_body = AdminState,
214    responses(
215        (status = 200, description = "Admin state changed successfully"),
216        (status = 400, description = "Cannot demote yourself"),
217        (status = 403, description = "Admin access required")
218    ),
219    security(("session_cookie" = []))
220)]
221pub async fn admin(
222    user: AdminUser,
223    Path(name): Path<String>,
224    State(db): DbState,
225    State(cache): TokenCacheState,
226    Json(admin_state): Json<AdminState>,
227) -> Result<(), RouteError> {
228    // Prevent self-demotion to avoid lockout
229    if user.name() == name && !admin_state.state {
230        return Err(RouteError::Status(StatusCode::BAD_REQUEST));
231    }
232
233    db.change_admin_state(&name, admin_state.state).await?;
234
235    cache.invalidate_all();
236
237    Ok(())
238}
239
240/// Delete a user (admin only)
241#[utoipa::path(
242    delete,
243    path = "/{name}",
244    tag = "users",
245    params(
246        ("name" = String, Path, description = "Username to delete")
247    ),
248    responses(
249        (status = 200, description = "User deleted successfully"),
250        (status = 400, description = "Cannot delete yourself"),
251        (status = 403, description = "Admin access required")
252    ),
253    security(("session_cookie" = []))
254)]
255pub async fn delete(
256    user: AdminUser,
257    Path(name): Path<String>,
258    State(db): DbState,
259    State(cache): TokenCacheState,
260) -> Result<(), RouteError> {
261    // Prevent self-deletion to avoid lockout
262    if user.name() == name {
263        return Err(RouteError::Status(StatusCode::BAD_REQUEST));
264    }
265
266    db.delete_user(&name).await?;
267
268    cache.invalidate_all();
269
270    Ok(())
271}
272
273#[derive(Serialize, ToSchema)]
274pub struct LoggedInUser {
275    user: String,
276    is_admin: bool,
277    is_logged_in: bool,
278}
279
280#[derive(Deserialize, ToSchema)]
281pub struct Credentials {
282    pub user: String,
283    pub pwd: String,
284}
285
286impl Credentials {
287    pub fn validate(&self) -> Result<(), RouteError> {
288        if self.user.is_empty() {
289            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
290        }
291        if self.pwd.is_empty() {
292            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
293        }
294        Ok(())
295    }
296}
297
298/// Login with username and password
299#[utoipa::path(
300    post,
301    path = "/login",
302    tag = "auth",
303    request_body = Credentials,
304    responses(
305        (status = 200, description = "Successfully logged in", body = LoggedInUser),
306        (status = 400, description = "Invalid credentials"),
307        (status = 401, description = "Authentication failed")
308    )
309)]
310pub async fn login(
311    cookies: PrivateCookieJar,
312    State(state): AppState,
313    Json(credentials): Json<Credentials>,
314) -> Result<(PrivateCookieJar, Json<LoggedInUser>), RouteError> {
315    credentials.validate()?;
316
317    let user = state
318        .db
319        .authenticate_user(&credentials.user, &credentials.pwd)
320        .await
321        .map_err(|_| RouteError::AuthenticationFailure)?;
322
323    let jar = create_session_jar(cookies, &state, &credentials.user).await?;
324
325    Ok((
326        jar,
327        LoggedInUser {
328            user: credentials.user.clone(),
329            is_admin: user.is_admin,
330            is_logged_in: true,
331        }
332        .into(),
333    ))
334}
335
336/// Get current login state
337#[utoipa::path(
338    get,
339    path = "/state",
340    tag = "auth",
341    responses(
342        (status = 200, description = "Current login state", body = LoggedInUser)
343    )
344)]
345#[expect(clippy::unused_async)] // part of the router
346pub async fn login_state(user: Option<MaybeUser>) -> Json<LoggedInUser> {
347    match user {
348        Some(MaybeUser::Normal(user)) => LoggedInUser {
349            user,
350            is_admin: false,
351            is_logged_in: true,
352        },
353        Some(MaybeUser::Admin(user)) => LoggedInUser {
354            user,
355            is_admin: true,
356            is_logged_in: true,
357        },
358        None => LoggedInUser {
359            user: String::new(),
360            is_admin: false,
361            is_logged_in: false,
362        },
363    }
364    .into()
365}
366
367/// Logout and clear session
368#[utoipa::path(
369    post,
370    path = "/logout",
371    tag = "auth",
372    responses(
373        (status = 200, description = "Successfully logged out")
374    )
375)]
376pub async fn logout(
377    mut jar: PrivateCookieJar,
378    State(state): AppState,
379) -> Result<PrivateCookieJar, RouteError> {
380    let session_id = match jar.get(COOKIE_SESSION_ID) {
381        Some(c) => c.value().to_owned(),
382        None => return Ok(jar), // Already logged out as no cookie can be found
383    };
384
385    jar = jar.remove(COOKIE_SESSION_ID);
386    jar = jar.remove(Cookie::build((COOKIE_SESSION_USER, "")).path("/"));
387
388    state.db.delete_session_token(&session_id).await?;
389    Ok(jar)
390}
391
392#[derive(Deserialize, ToSchema)]
393pub struct PwdChange {
394    pub old_pwd: String,
395    pub new_pwd1: String,
396    pub new_pwd2: String,
397}
398
399impl PwdChange {
400    pub fn validate(&self) -> Result<(), RouteError> {
401        if self.old_pwd.is_empty() {
402            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
403        }
404        if self.new_pwd1.is_empty() || self.new_pwd2.is_empty() {
405            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
406        }
407        if self.new_pwd1 != self.new_pwd2 {
408            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
409        }
410        Ok(())
411    }
412}
413
414/// Change the current user's password
415#[utoipa::path(
416    put,
417    path = "/me/password",
418    tag = "users",
419    request_body = PwdChange,
420    responses(
421        (status = 200, description = "Password changed successfully"),
422        (status = 400, description = "Invalid password or validation failed"),
423        (status = 401, description = "Not authenticated")
424    ),
425    security(("session_cookie" = []))
426)]
427pub async fn change_pwd(
428    user: MaybeUser,
429    State(db): DbState,
430    Json(pwd_change): Json<PwdChange>,
431) -> Result<(), RouteError> {
432    pwd_change.validate()?;
433
434    let Ok(user) = db.authenticate_user(user.name(), &pwd_change.old_pwd).await else {
435        return Err(RouteError::Status(StatusCode::BAD_REQUEST));
436    };
437
438    db.change_pwd(&user.name, &pwd_change.new_pwd1).await?;
439    Ok(())
440}
441
442#[derive(Deserialize, ToSchema)]
443pub struct NewUser {
444    pub pwd1: String,
445    pub pwd2: String,
446    pub name: String,
447    #[serde(default)] // Set to false if not in message from client
448    pub is_admin: bool,
449    #[serde(default)] // Set to false if not in message from client
450    pub is_read_only: bool,
451}
452
453impl NewUser {
454    pub fn validate(&self) -> Result<(), RouteError> {
455        if self.name.is_empty() {
456            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
457        }
458        if self.pwd1.is_empty() || self.pwd2.is_empty() {
459            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
460        }
461        if self.pwd1 != self.pwd2 {
462            return Err(RouteError::Status(StatusCode::BAD_REQUEST));
463        }
464        Ok(())
465    }
466}
467
468/// Create a new user (admin only)
469#[utoipa::path(
470    post,
471    path = "/",
472    tag = "users",
473    request_body = NewUser,
474    responses(
475        (status = 200, description = "User created successfully"),
476        (status = 400, description = "Validation failed"),
477        (status = 403, description = "Admin access required")
478    ),
479    security(("session_cookie" = []))
480)]
481pub async fn add(
482    _user: AdminUser,
483    State(db): DbState,
484    State(cache): TokenCacheState,
485    Json(new_user): Json<NewUser>,
486) -> Result<(), RouteError> {
487    new_user.validate()?;
488
489    let salt = generate_salt();
490    db.add_user(
491        &new_user.name,
492        &new_user.pwd1,
493        &salt,
494        new_user.is_admin,
495        new_user.is_read_only,
496    )
497    .await?;
498
499    cache.invalidate_all();
500
501    Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506    use std::sync::Arc;
507
508    use axum::Router;
509    use axum::body::Body;
510    use axum::routing::post;
511    use axum_extra::extract::cookie::Key;
512    use hyper::{Request, header};
513    use kellnr_appstate::AppStateData;
514    use kellnr_common::token_cache::{CachedTokenData, TokenCacheManager};
515    use kellnr_db::AuthToken;
516    use kellnr_db::error::DbError;
517    use kellnr_db::mock::MockDb;
518    use kellnr_settings::constants::COOKIE_SESSION_ID;
519    use kellnr_storage::cached_crate_storage::DynStorage;
520    use kellnr_storage::cratesio_crate_storage::CratesIoCrateStorage;
521    use kellnr_storage::fs_storage::FSStorage;
522    use kellnr_storage::kellnr_crate_storage::KellnrCrateStorage;
523    use mockall::predicate::*;
524    use tower::ServiceExt;
525
526    use super::*;
527    use crate::test_helper::{TEST_KEY, encode_cookies};
528
529    fn test_state_with_cache(mock_db: MockDb, cache: Arc<TokenCacheManager>) -> AppStateData {
530        let settings = Arc::new(kellnr_settings::test_settings());
531        let kellnr_storage =
532            Box::new(FSStorage::new(&settings.crates_path()).unwrap()) as DynStorage;
533        let crate_storage = Arc::new(KellnrCrateStorage::new(&settings, kellnr_storage));
534        let cratesio_storage = Arc::new(CratesIoCrateStorage::new(
535            &settings,
536            Box::new(FSStorage::new(&settings.crates_io_path()).unwrap()) as DynStorage,
537        ));
538        let (cratesio_prefetch_sender, _) = flume::unbounded();
539        let db: Arc<dyn kellnr_db::DbProvider> = Arc::new(mock_db);
540        let download_counter = Arc::new(kellnr_db::download_counter::DownloadCounter::new(
541            db.clone(),
542            30,
543        ));
544
545        AppStateData {
546            db,
547            signing_key: Key::from(TEST_KEY),
548            settings,
549            crate_storage,
550            cratesio_storage,
551            cratesio_prefetch_sender,
552            token_cache: cache,
553            toolchain_storage: None,
554            download_counter,
555            proxy_client: kellnr_common::cratesio_downloader::CLIENT.clone(),
556        }
557    }
558
559    #[tokio::test]
560    async fn test_add_token_invalidates_cache() {
561        // Pre-populate cache with a token
562        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
563        cache
564            .insert(
565                "existing_token".to_string(),
566                CachedTokenData {
567                    user: "test_user".to_string(),
568                    is_admin: false,
569                    is_read_only: false,
570                },
571            )
572            .await;
573
574        // Verify token is cached
575        assert!(cache.get("existing_token").await.is_some());
576
577        let mut mock_db = MockDb::new();
578        mock_db
579            .expect_validate_session()
580            .times(1)
581            .returning(|_| Ok(("test_user".to_string(), false)));
582        mock_db
583            .expect_add_auth_token()
584            .times(1)
585            .returning(|_, _, _| Ok(()));
586
587        let state = test_state_with_cache(mock_db, cache.clone());
588        let app = Router::new()
589            .route("/add_token", post(add_token))
590            .with_state(state);
591
592        let response = app
593            .oneshot(
594                Request::post("/add_token")
595                    .header(
596                        header::COOKIE,
597                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
598                    )
599                    .header(header::CONTENT_TYPE, "application/json")
600                    .body(Body::from(r#"{"name":"new_token"}"#))
601                    .unwrap(),
602            )
603            .await
604            .unwrap();
605
606        assert!(
607            response.status().is_success(),
608            "Expected success but got {}",
609            response.status()
610        );
611
612        // Verify cache was invalidated
613        assert!(cache.get("existing_token").await.is_none());
614    }
615
616    #[tokio::test]
617    async fn test_delete_token_invalidates_cache() {
618        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
619        cache
620            .insert(
621                "token_to_keep".to_string(),
622                CachedTokenData {
623                    user: "test_user".to_string(),
624                    is_admin: false,
625                    is_read_only: false,
626                },
627            )
628            .await;
629
630        assert!(cache.get("token_to_keep").await.is_some());
631
632        let mut mock_db = MockDb::new();
633        mock_db
634            .expect_validate_session()
635            .times(1)
636            .returning(|_| Ok(("test_user".to_string(), false)));
637        mock_db.expect_get_auth_tokens().times(1).returning(|_| {
638            Ok(vec![AuthToken::new(
639                1,
640                "token".to_string(),
641                "secret".to_string(),
642            )])
643        });
644        mock_db
645            .expect_delete_auth_token()
646            .times(1)
647            .with(eq(1))
648            .returning(|_| Ok(()));
649
650        let state = test_state_with_cache(mock_db, cache.clone());
651        let app = Router::new()
652            .route("/delete_token/{id}", axum::routing::delete(delete_token))
653            .with_state(state);
654
655        let response = app
656            .oneshot(
657                Request::delete("/delete_token/1")
658                    .header(
659                        header::COOKIE,
660                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
661                    )
662                    .body(Body::empty())
663                    .unwrap(),
664            )
665            .await
666            .unwrap();
667
668        assert!(
669            response.status().is_success(),
670            "Expected success but got {}",
671            response.status()
672        );
673
674        // Verify cache was invalidated
675        assert!(cache.get("token_to_keep").await.is_none());
676    }
677
678    #[tokio::test]
679    async fn test_delete_user_invalidates_cache() {
680        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
681        cache
682            .insert(
683                "user_token".to_string(),
684                CachedTokenData {
685                    user: "user_to_delete".to_string(),
686                    is_admin: false,
687                    is_read_only: false,
688                },
689            )
690            .await;
691
692        assert!(cache.get("user_token").await.is_some());
693
694        let mut mock_db = MockDb::new();
695        mock_db
696            .expect_validate_session()
697            .times(1)
698            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
699        mock_db
700            .expect_delete_user()
701            .times(1)
702            .with(eq("user_to_delete"))
703            .returning(|_| Ok(()));
704
705        let state = test_state_with_cache(mock_db, cache.clone());
706        let app = Router::new()
707            .route("/delete/{name}", axum::routing::delete(delete))
708            .with_state(state);
709
710        let response = app
711            .oneshot(
712                Request::delete("/delete/user_to_delete")
713                    .header(
714                        header::COOKIE,
715                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
716                    )
717                    .body(Body::empty())
718                    .unwrap(),
719            )
720            .await
721            .unwrap();
722
723        assert!(
724            response.status().is_success(),
725            "Expected success but got {}",
726            response.status()
727        );
728
729        // Verify cache was invalidated
730        assert!(cache.get("user_token").await.is_none());
731    }
732
733    #[tokio::test]
734    async fn test_read_only_change_invalidates_cache() {
735        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
736        cache
737            .insert(
738                "user_token".to_string(),
739                CachedTokenData {
740                    user: "target_user".to_string(),
741                    is_admin: false,
742                    is_read_only: false, // Currently NOT read-only
743                },
744            )
745            .await;
746
747        assert!(cache.get("user_token").await.is_some());
748
749        let mut mock_db = MockDb::new();
750        mock_db
751            .expect_validate_session()
752            .times(1)
753            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
754        mock_db
755            .expect_change_read_only_state()
756            .times(1)
757            .with(eq("target_user"), eq(true))
758            .returning(|_, _| Ok(()));
759
760        let state = test_state_with_cache(mock_db, cache.clone());
761        let app = Router::new()
762            .route("/read_only/{name}", post(read_only))
763            .with_state(state);
764
765        let response = app
766            .oneshot(
767                Request::post("/read_only/target_user")
768                    .header(
769                        header::COOKIE,
770                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
771                    )
772                    .header(header::CONTENT_TYPE, "application/json")
773                    .body(Body::from(r#"{"state":true}"#))
774                    .unwrap(),
775            )
776            .await
777            .unwrap();
778
779        assert!(
780            response.status().is_success(),
781            "Expected success but got {}",
782            response.status()
783        );
784
785        // Verify cache was invalidated - important because is_read_only permission changed
786        assert!(cache.get("user_token").await.is_none());
787    }
788
789    #[tokio::test]
790    async fn test_admin_self_locking_prevented() {
791        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
792
793        let mut mock_db = MockDb::new();
794        mock_db
795            .expect_validate_session()
796            .times(1)
797            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
798        // Note: change_read_only_state should NOT be called because self-locking is blocked
799
800        let state = test_state_with_cache(mock_db, cache.clone());
801        let app = Router::new()
802            .route("/read_only/{name}", post(read_only))
803            .with_state(state);
804
805        let response = app
806            .oneshot(
807                Request::post("/read_only/admin") // Trying to lock self
808                    .header(
809                        header::COOKIE,
810                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
811                    )
812                    .header(header::CONTENT_TYPE, "application/json")
813                    .body(Body::from(r#"{"state":true}"#)) // Locking (state=true)
814                    .unwrap(),
815            )
816            .await
817            .unwrap();
818
819        // Request should fail with Bad Request
820        assert_eq!(
821            response.status(),
822            StatusCode::BAD_REQUEST,
823            "Expected BAD_REQUEST but got {}",
824            response.status()
825        );
826    }
827
828    #[tokio::test]
829    async fn test_admin_self_unlocking_allowed() {
830        // An admin unlocking themselves (state=false) should be allowed
831        // Only self-locking (state=true) is blocked
832        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
833
834        let mut mock_db = MockDb::new();
835        mock_db
836            .expect_validate_session()
837            .times(1)
838            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
839        mock_db
840            .expect_change_read_only_state()
841            .times(1)
842            .with(eq("admin"), eq(false)) // Self-unlocking
843            .returning(|_, _| Ok(()));
844
845        let state = test_state_with_cache(mock_db, cache.clone());
846        let app = Router::new()
847            .route("/read_only/{name}", post(read_only))
848            .with_state(state);
849
850        let response = app
851            .oneshot(
852                Request::post("/read_only/admin") // Same user
853                    .header(
854                        header::COOKIE,
855                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
856                    )
857                    .header(header::CONTENT_TYPE, "application/json")
858                    .body(Body::from(r#"{"state":false}"#)) // Unlocking (state=false)
859                    .unwrap(),
860            )
861            .await
862            .unwrap();
863
864        assert!(
865            response.status().is_success(),
866            "Expected success but got {}",
867            response.status()
868        );
869    }
870
871    #[tokio::test]
872    async fn test_admin_locking_other_user_works() {
873        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
874
875        let mut mock_db = MockDb::new();
876        mock_db
877            .expect_validate_session()
878            .times(1)
879            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
880        mock_db
881            .expect_change_read_only_state()
882            .times(1)
883            .with(eq("other_user"), eq(true))
884            .returning(|_, _| Ok(()));
885
886        let state = test_state_with_cache(mock_db, cache.clone());
887        let app = Router::new()
888            .route("/read_only/{name}", post(read_only))
889            .with_state(state);
890
891        let response = app
892            .oneshot(
893                Request::post("/read_only/other_user") // Locking another user
894                    .header(
895                        header::COOKIE,
896                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
897                    )
898                    .header(header::CONTENT_TYPE, "application/json")
899                    .body(Body::from(r#"{"state":true}"#))
900                    .unwrap(),
901            )
902            .await
903            .unwrap();
904
905        assert!(
906            response.status().is_success(),
907            "Expected success but got {}",
908            response.status()
909        );
910    }
911
912    #[tokio::test]
913    async fn test_add_user_invalidates_cache() {
914        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
915        cache
916            .insert(
917                "existing_token".to_string(),
918                CachedTokenData {
919                    user: "existing_user".to_string(),
920                    is_admin: false,
921                    is_read_only: false,
922                },
923            )
924            .await;
925
926        assert!(cache.get("existing_token").await.is_some());
927
928        let mut mock_db = MockDb::new();
929        mock_db
930            .expect_validate_session()
931            .times(1)
932            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
933        mock_db
934            .expect_add_user()
935            .times(1)
936            .returning(|_, _, _, _, _| Ok(()));
937
938        let state = test_state_with_cache(mock_db, cache.clone());
939        let app = Router::new().route("/add", post(add)).with_state(state);
940
941        let response = app
942            .oneshot(
943                Request::post("/add")
944                    .header(header::COOKIE, encode_cookies([(COOKIE_SESSION_ID, "session")]))
945                    .header(header::CONTENT_TYPE, "application/json")
946                    .body(Body::from(r#"{"name":"new_user","pwd1":"password","pwd2":"password","is_admin":false,"is_read_only":false}"#))
947                    .unwrap(),
948            )
949            .await
950            .unwrap();
951
952        assert!(
953            response.status().is_success(),
954            "Expected success but got {}",
955            response.status()
956        );
957
958        // Verify cache was invalidated
959        assert!(cache.get("existing_token").await.is_none());
960    }
961
962    #[tokio::test]
963    async fn test_cache_not_invalidated_on_db_failure() {
964        // Verify cache is NOT invalidated when DB operation fails
965        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
966        cache
967            .insert(
968                "existing_token".to_string(),
969                CachedTokenData {
970                    user: "test_user".to_string(),
971                    is_admin: false,
972                    is_read_only: false,
973                },
974            )
975            .await;
976
977        assert!(cache.get("existing_token").await.is_some());
978
979        let mut mock_db = MockDb::new();
980        mock_db
981            .expect_validate_session()
982            .times(1)
983            .returning(|_| Ok(("test_user".to_string(), false)));
984        mock_db
985            .expect_add_auth_token()
986            .times(1)
987            .returning(|_, _, _| {
988                Err(DbError::InitializationError(
989                    "Connection timeout".to_string(),
990                ))
991            });
992
993        let state = test_state_with_cache(mock_db, cache.clone());
994        let app = Router::new()
995            .route("/add_token", post(add_token))
996            .with_state(state);
997
998        let response = app
999            .oneshot(
1000                Request::post("/add_token")
1001                    .header(
1002                        header::COOKIE,
1003                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1004                    )
1005                    .header(header::CONTENT_TYPE, "application/json")
1006                    .body(Body::from(r#"{"name":"new_token"}"#))
1007                    .unwrap(),
1008            )
1009            .await
1010            .unwrap();
1011
1012        // Request should have failed
1013        assert!(!response.status().is_success());
1014
1015        // Cache should still contain the token since operation failed
1016        assert!(cache.get("existing_token").await.is_some());
1017    }
1018
1019    #[tokio::test]
1020    async fn test_admin_change_invalidates_cache() {
1021        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1022        cache
1023            .insert(
1024                "user_token".to_string(),
1025                CachedTokenData {
1026                    user: "target_user".to_string(),
1027                    is_admin: false, // Currently NOT admin
1028                    is_read_only: false,
1029                },
1030            )
1031            .await;
1032
1033        assert!(cache.get("user_token").await.is_some());
1034
1035        let mut mock_db = MockDb::new();
1036        mock_db
1037            .expect_validate_session()
1038            .times(1)
1039            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1040        mock_db
1041            .expect_change_admin_state()
1042            .times(1)
1043            .with(eq("target_user"), eq(true))
1044            .returning(|_, _| Ok(()));
1045
1046        let state = test_state_with_cache(mock_db, cache.clone());
1047        let app = Router::new()
1048            .route("/admin/{name}", post(admin))
1049            .with_state(state);
1050
1051        let response = app
1052            .oneshot(
1053                Request::post("/admin/target_user")
1054                    .header(
1055                        header::COOKIE,
1056                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1057                    )
1058                    .header(header::CONTENT_TYPE, "application/json")
1059                    .body(Body::from(r#"{"state":true}"#))
1060                    .unwrap(),
1061            )
1062            .await
1063            .unwrap();
1064
1065        assert!(
1066            response.status().is_success(),
1067            "Expected success but got {}",
1068            response.status()
1069        );
1070
1071        // Verify cache was invalidated - important because is_admin permission changed
1072        assert!(cache.get("user_token").await.is_none());
1073    }
1074
1075    #[tokio::test]
1076    async fn test_admin_self_demotion_prevented() {
1077        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1078
1079        let mut mock_db = MockDb::new();
1080        mock_db
1081            .expect_validate_session()
1082            .times(1)
1083            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1084        // Note: change_admin_state should NOT be called because self-demotion is blocked
1085
1086        let state = test_state_with_cache(mock_db, cache.clone());
1087        let app = Router::new()
1088            .route("/admin/{name}", post(admin))
1089            .with_state(state);
1090
1091        let response = app
1092            .oneshot(
1093                Request::post("/admin/admin") // Trying to demote self
1094                    .header(
1095                        header::COOKIE,
1096                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1097                    )
1098                    .header(header::CONTENT_TYPE, "application/json")
1099                    .body(Body::from(r#"{"state":false}"#)) // Demoting (state=false)
1100                    .unwrap(),
1101            )
1102            .await
1103            .unwrap();
1104
1105        // Request should fail with Bad Request
1106        assert_eq!(
1107            response.status(),
1108            StatusCode::BAD_REQUEST,
1109            "Expected BAD_REQUEST but got {}",
1110            response.status()
1111        );
1112    }
1113
1114    #[tokio::test]
1115    async fn test_non_admin_cannot_change_admin_status() {
1116        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1117
1118        let mut mock_db = MockDb::new();
1119        mock_db
1120            .expect_validate_session()
1121            .times(1)
1122            .returning(|_| Ok(("regular_user".to_string(), false))); // NOT admin
1123
1124        let state = test_state_with_cache(mock_db, cache.clone());
1125        let app = Router::new()
1126            .route("/admin/{name}", post(admin))
1127            .with_state(state);
1128
1129        let response = app
1130            .oneshot(
1131                Request::post("/admin/target_user")
1132                    .header(
1133                        header::COOKIE,
1134                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1135                    )
1136                    .header(header::CONTENT_TYPE, "application/json")
1137                    .body(Body::from(r#"{"state":true}"#))
1138                    .unwrap(),
1139            )
1140            .await
1141            .unwrap();
1142
1143        // Request should fail with Forbidden
1144        assert_eq!(
1145            response.status(),
1146            StatusCode::FORBIDDEN,
1147            "Expected FORBIDDEN but got {}",
1148            response.status()
1149        );
1150    }
1151
1152    #[tokio::test]
1153    async fn test_admin_demotion_works() {
1154        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1155
1156        let mut mock_db = MockDb::new();
1157        mock_db
1158            .expect_validate_session()
1159            .times(1)
1160            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1161        mock_db
1162            .expect_change_admin_state()
1163            .times(1)
1164            .with(eq("other_admin"), eq(false)) // Demoting other_admin
1165            .returning(|_, _| Ok(()));
1166
1167        let state = test_state_with_cache(mock_db, cache.clone());
1168        let app = Router::new()
1169            .route("/admin/{name}", post(admin))
1170            .with_state(state);
1171
1172        let response = app
1173            .oneshot(
1174                Request::post("/admin/other_admin")
1175                    .header(
1176                        header::COOKIE,
1177                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1178                    )
1179                    .header(header::CONTENT_TYPE, "application/json")
1180                    .body(Body::from(r#"{"state":false}"#)) // Demoting
1181                    .unwrap(),
1182            )
1183            .await
1184            .unwrap();
1185
1186        assert!(
1187            response.status().is_success(),
1188            "Expected success but got {}",
1189            response.status()
1190        );
1191    }
1192
1193    #[tokio::test]
1194    async fn test_admin_nonexistent_user_returns_error() {
1195        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1196
1197        let mut mock_db = MockDb::new();
1198        mock_db
1199            .expect_validate_session()
1200            .times(1)
1201            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1202        mock_db
1203            .expect_change_admin_state()
1204            .times(1)
1205            .with(eq("nonexistent"), eq(true))
1206            .returning(|_, _| Err(DbError::UserNotFound("nonexistent".to_string())));
1207
1208        let state = test_state_with_cache(mock_db, cache.clone());
1209        let app = Router::new()
1210            .route("/admin/{name}", post(admin))
1211            .with_state(state);
1212
1213        let response = app
1214            .oneshot(
1215                Request::post("/admin/nonexistent")
1216                    .header(
1217                        header::COOKIE,
1218                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1219                    )
1220                    .header(header::CONTENT_TYPE, "application/json")
1221                    .body(Body::from(r#"{"state":true}"#))
1222                    .unwrap(),
1223            )
1224            .await
1225            .unwrap();
1226
1227        // Request should fail with Not Found
1228        assert_eq!(
1229            response.status(),
1230            StatusCode::NOT_FOUND,
1231            "Expected NOT_FOUND but got {}",
1232            response.status()
1233        );
1234    }
1235
1236    #[tokio::test]
1237    async fn test_admin_self_promotion_allowed() {
1238        // An admin promoting themselves (state=true) should be allowed
1239        // Only self-demotion (state=false) is blocked
1240        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1241
1242        let mut mock_db = MockDb::new();
1243        mock_db
1244            .expect_validate_session()
1245            .times(1)
1246            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1247        mock_db
1248            .expect_change_admin_state()
1249            .times(1)
1250            .with(eq("admin"), eq(true)) // Self-promotion (no-op but allowed)
1251            .returning(|_, _| Ok(()));
1252
1253        let state = test_state_with_cache(mock_db, cache.clone());
1254        let app = Router::new()
1255            .route("/admin/{name}", post(admin))
1256            .with_state(state);
1257
1258        let response = app
1259            .oneshot(
1260                Request::post("/admin/admin") // Same user
1261                    .header(
1262                        header::COOKIE,
1263                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1264                    )
1265                    .header(header::CONTENT_TYPE, "application/json")
1266                    .body(Body::from(r#"{"state":true}"#)) // Promoting (state=true)
1267                    .unwrap(),
1268            )
1269            .await
1270            .unwrap();
1271
1272        assert!(
1273            response.status().is_success(),
1274            "Expected success but got {}",
1275            response.status()
1276        );
1277    }
1278
1279    #[tokio::test]
1280    async fn test_admin_self_deletion_prevented() {
1281        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1282
1283        let mut mock_db = MockDb::new();
1284        mock_db
1285            .expect_validate_session()
1286            .times(1)
1287            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1288        // Note: delete_user should NOT be called because self-deletion is blocked
1289
1290        let state = test_state_with_cache(mock_db, cache.clone());
1291        let app = Router::new()
1292            .route("/delete/{name}", axum::routing::delete(delete))
1293            .with_state(state);
1294
1295        let response = app
1296            .oneshot(
1297                Request::delete("/delete/admin") // Trying to delete self
1298                    .header(
1299                        header::COOKIE,
1300                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1301                    )
1302                    .body(Body::empty())
1303                    .unwrap(),
1304            )
1305            .await
1306            .unwrap();
1307
1308        // Request should fail with Bad Request
1309        assert_eq!(
1310            response.status(),
1311            StatusCode::BAD_REQUEST,
1312            "Expected BAD_REQUEST but got {}",
1313            response.status()
1314        );
1315    }
1316
1317    #[tokio::test]
1318    async fn test_admin_deletion_of_other_user_works() {
1319        let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
1320
1321        let mut mock_db = MockDb::new();
1322        mock_db
1323            .expect_validate_session()
1324            .times(1)
1325            .returning(|_| Ok(("admin".to_string(), true))); // Must be admin
1326        mock_db
1327            .expect_delete_user()
1328            .times(1)
1329            .with(eq("other_user"))
1330            .returning(|_| Ok(()));
1331
1332        let state = test_state_with_cache(mock_db, cache.clone());
1333        let app = Router::new()
1334            .route("/delete/{name}", axum::routing::delete(delete))
1335            .with_state(state);
1336
1337        let response = app
1338            .oneshot(
1339                Request::delete("/delete/other_user") // Deleting another user
1340                    .header(
1341                        header::COOKIE,
1342                        encode_cookies([(COOKIE_SESSION_ID, "session")]),
1343                    )
1344                    .body(Body::empty())
1345                    .unwrap(),
1346            )
1347            .await
1348            .unwrap();
1349
1350        assert!(
1351            response.status().is_success(),
1352            "Expected success but got {}",
1353            response.status()
1354        );
1355    }
1356}