Skip to main content

kellnr_web_ui/
user.rs

1use axum::extract::{Path, State};
2use axum::http::StatusCode;
3use axum::{Extension, Json};
4use axum_extra::extract::PrivateCookieJar;
5use axum_extra::extract::cookie::Cookie;
6use kellnr_common::account_name::{UserName, validate_token_name};
7use kellnr_common::plain_token::PlainToken;
8use kellnr_common::util::generate_rand_string;
9use kellnr_db::{self, AuthToken, AuthTokenId, DbProvider, User, UserFlags};
10use kellnr_http::error::ApiError;
11use kellnr_http::identity::{AdminUser, SessionUser};
12use kellnr_http::password::{PasswordAuthError, authenticate_password};
13use kellnr_http::state::{AppState, DbState, LoginThrottleState, SettingsState, TokenCacheState};
14use kellnr_http::token;
15use kellnr_settings::Settings;
16use kellnr_settings::constants::{COOKIE_OIDC_ID_TOKEN, COOKIE_SESSION_ID, COOKIE_SESSION_USER};
17use serde::{Deserialize, Serialize};
18use tracing::warn;
19use utoipa::ToSchema;
20
21use crate::oauth2::OAuth2Ext;
22use crate::session::create_session_jar;
23
24/// Reject local password operations when SSO is enforced.
25fn ensure_local_auth_enabled(settings: &Settings) -> Result<(), ApiError> {
26    if settings.oauth2.enforced {
27        return Err(ApiError::from(StatusCode::FORBIDDEN));
28    }
29    Ok(())
30}
31
32/// Logs `user_name` out everywhere after a password change, except for the
33/// session `keep_session_token` if given. API tokens are left untouched so CI
34/// pipelines keep working.
35async fn revoke_sessions(
36    db: &dyn DbProvider,
37    user_name: &UserName,
38    keep_session_token: Option<&str>,
39) -> Result<(), ApiError> {
40    match keep_session_token {
41        Some(token) => {
42            db.delete_other_user_sessions(user_name, &PlainToken::from(token))
43                .await?
44        }
45        None => db.delete_user_sessions(user_name).await?,
46    };
47    Ok(())
48}
49
50#[derive(Serialize, ToSchema)]
51pub struct NewTokenResponse {
52    name: String,
53    token: String,
54}
55
56/// Add a new auth token for the current user
57#[utoipa::path(
58    post,
59    path = "/me/tokens",
60    tag = "users",
61    request_body = token::NewTokenReqData,
62    responses(
63        (status = 200, description = "Token created successfully", body = NewTokenResponse),
64        (status = 400, description = "Invalid token name"),
65        (status = 401, description = "Not authenticated")
66    ),
67    security(("session_cookie" = []))
68)]
69pub async fn add_token(
70    user: SessionUser,
71    State(db): DbState,
72    State(cache): TokenCacheState,
73    Json(auth_token): Json<token::NewTokenReqData>,
74) -> Result<Json<NewTokenResponse>, ApiError> {
75    validate_token_name(&auth_token.name)?;
76    let token = token::generate_token();
77    db.add_auth_token(&auth_token.name, &token, &user.name)
78        .await?;
79
80    cache.invalidate_all();
81
82    Ok(NewTokenResponse {
83        name: auth_token.name.clone(),
84        token: token.into_secret(),
85    }
86    .into())
87}
88
89/// List auth tokens for the current user
90#[utoipa::path(
91    get,
92    path = "/me/tokens",
93    tag = "users",
94    responses(
95        (status = 200, description = "List of auth tokens", body = Vec<AuthToken>),
96        (status = 401, description = "Not authenticated")
97    ),
98    security(("session_cookie" = []))
99)]
100pub async fn list_tokens(
101    user: SessionUser,
102    State(db): DbState,
103) -> Result<Json<Vec<AuthToken>>, ApiError> {
104    Ok(Json(db.get_auth_tokens(&user.name).await?))
105}
106
107/// List all users (admin only)
108#[utoipa::path(
109    get,
110    path = "/",
111    tag = "users",
112    responses(
113        (status = 200, description = "List of all users", body = Vec<User>),
114        (status = 403, description = "Admin access required")
115    ),
116    security(("session_cookie" = []))
117)]
118pub async fn list_users(_user: AdminUser, State(db): DbState) -> Result<Json<Vec<User>>, ApiError> {
119    Ok(Json(db.get_users().await?))
120}
121
122/// Delete an auth token
123#[utoipa::path(
124    delete,
125    path = "/me/tokens/{id}",
126    tag = "users",
127    params(
128        ("id" = i64, Path, description = "Token ID to delete")
129    ),
130    responses(
131        (status = 200, description = "Token deleted successfully"),
132        (status = 401, description = "Not authenticated"),
133        (status = 404, description = "Token not found")
134    ),
135    security(("session_cookie" = []))
136)]
137pub async fn delete_token(
138    user: SessionUser,
139    Path(id): Path<AuthTokenId>,
140    State(db): DbState,
141    State(cache): TokenCacheState,
142) -> Result<(), ApiError> {
143    // Only deletes a token of the user
144    db.delete_auth_token(&user.name, id).await?;
145
146    cache.invalidate_all();
147
148    Ok(())
149}
150
151#[derive(Serialize, ToSchema)]
152pub struct ResetPwd {
153    new_pwd: String,
154    user: UserName,
155}
156
157/// Reset a user's password (admin only)
158#[utoipa::path(
159    put,
160    path = "/{name}/password",
161    tag = "users",
162    params(
163        ("name" = String, Path, description = "Username")
164    ),
165    responses(
166        (status = 200, description = "Password reset successfully", body = ResetPwd),
167        (status = 403, description = "Admin access required")
168    ),
169    security(("session_cookie" = []))
170)]
171pub async fn reset_pwd(
172    admin: AdminUser,
173    jar: PrivateCookieJar,
174    Path(name): Path<UserName>,
175    State(db): DbState,
176    State(cache): TokenCacheState,
177    State(settings): SettingsState,
178) -> Result<Json<ResetPwd>, ApiError> {
179    ensure_local_auth_enabled(&settings)?;
180
181    let new_pwd = generate_rand_string(12);
182    db.change_pwd(&name, &new_pwd).await?;
183
184    // An admin resetting their own password stays logged in.
185    let keep_session = jar.get(COOKIE_SESSION_ID).filter(|_| admin.name == name);
186    revoke_sessions(db.as_ref(), &name, keep_session.as_ref().map(Cookie::value)).await?;
187
188    // Drop cached basic auth credentials carrying the old password.
189    cache.invalidate_all();
190
191    // Report the user whose password was reset, not the admin performing it.
192    Ok(ResetPwd {
193        user: name,
194        new_pwd,
195    }
196    .into())
197}
198
199#[derive(Deserialize, ToSchema)]
200pub struct ReadOnlyState {
201    pub state: bool,
202}
203
204/// Change a user's read-only state (admin only)
205#[utoipa::path(
206    post,
207    path = "/{name}/read-only",
208    tag = "users",
209    params(
210        ("name" = String, Path, description = "Username")
211    ),
212    request_body = ReadOnlyState,
213    responses(
214        (status = 200, description = "Read-only state changed successfully"),
215        (status = 400, description = "Cannot lock yourself"),
216        (status = 403, description = "Admin access required")
217    ),
218    security(("session_cookie" = []))
219)]
220pub async fn read_only(
221    user: AdminUser,
222    Path(name): Path<UserName>,
223    State(db): DbState,
224    State(cache): TokenCacheState,
225    Json(ro_state): Json<ReadOnlyState>,
226) -> Result<(), ApiError> {
227    // Prevent self-locking to avoid lockout
228    if user.name == name && ro_state.state {
229        return Err(ApiError::bad_request("You cannot make yourself read-only"));
230    }
231
232    db.change_read_only_state(&name, ro_state.state).await?;
233
234    cache.invalidate_all();
235
236    Ok(())
237}
238
239#[derive(Deserialize, ToSchema)]
240pub struct AdminState {
241    pub state: bool,
242}
243
244/// Change a user's admin state (admin only)
245#[utoipa::path(
246    post,
247    path = "/{name}/admin",
248    tag = "users",
249    params(
250        ("name" = String, Path, description = "Username")
251    ),
252    request_body = AdminState,
253    responses(
254        (status = 200, description = "Admin state changed successfully"),
255        (status = 400, description = "Cannot demote yourself"),
256        (status = 403, description = "Admin access required")
257    ),
258    security(("session_cookie" = []))
259)]
260pub async fn admin(
261    user: AdminUser,
262    Path(name): Path<UserName>,
263    State(db): DbState,
264    State(cache): TokenCacheState,
265    Json(admin_state): Json<AdminState>,
266) -> Result<(), ApiError> {
267    // Prevent self-demotion to avoid lockout
268    if user.name == name && !admin_state.state {
269        return Err(ApiError::bad_request(
270            "You cannot remove your own admin rights",
271        ));
272    }
273
274    db.change_admin_state(&name, admin_state.state).await?;
275
276    cache.invalidate_all();
277
278    Ok(())
279}
280
281/// Delete a user (admin only)
282#[utoipa::path(
283    delete,
284    path = "/{name}",
285    tag = "users",
286    params(
287        ("name" = String, Path, description = "Username to delete")
288    ),
289    responses(
290        (status = 200, description = "User deleted successfully"),
291        (status = 400, description = "Cannot delete yourself"),
292        (status = 403, description = "Admin access required")
293    ),
294    security(("session_cookie" = []))
295)]
296pub async fn delete(
297    user: AdminUser,
298    Path(name): Path<UserName>,
299    State(db): DbState,
300    State(cache): TokenCacheState,
301) -> Result<(), ApiError> {
302    // Prevent self-deletion to avoid lockout
303    if user.name == name {
304        return Err(ApiError::bad_request("You cannot delete yourself"));
305    }
306
307    db.delete_user(&name).await?;
308
309    cache.invalidate_all();
310
311    Ok(())
312}
313
314#[derive(Serialize, ToSchema)]
315pub struct LoggedInUser {
316    user: String,
317    is_admin: bool,
318    is_logged_in: bool,
319}
320
321#[derive(Deserialize, ToSchema)]
322pub struct Credentials {
323    pub user: String,
324    pub pwd: String,
325}
326
327impl Credentials {
328    /// Returns the name of the user to log in.
329    pub fn validate(&self) -> Result<UserName, ApiError> {
330        let name = UserName::new(self.user.as_str())?;
331        if self.pwd.is_empty() {
332            return Err(ApiError::bad_request("The password must not be empty"));
333        }
334        Ok(name)
335    }
336}
337
338/// Login with username and password
339#[utoipa::path(
340    post,
341    path = "/login",
342    tag = "auth",
343    request_body = Credentials,
344    responses(
345        (status = 200, description = "Successfully logged in", body = LoggedInUser),
346        (status = 400, description = "Invalid credentials"),
347        (status = 401, description = "Authentication failed")
348    )
349)]
350pub async fn login(
351    cookies: PrivateCookieJar,
352    State(state): AppState,
353    Json(credentials): Json<Credentials>,
354) -> Result<(PrivateCookieJar, Json<LoggedInUser>), ApiError> {
355    ensure_local_auth_enabled(&state.settings)?;
356    let name = credentials.validate()?;
357
358    let user = authenticate_password(
359        state.db.as_ref(),
360        &state.login_throttle,
361        &name,
362        &credentials.pwd,
363    )
364    .await
365    .map_err(|e| match e {
366        PasswordAuthError::Throttled => ApiError::from(StatusCode::TOO_MANY_REQUESTS),
367        PasswordAuthError::Invalid => {
368            warn!("Login with wrong username or password");
369            ApiError::unauthorized("Wrong username or password")
370        }
371    })?;
372
373    let jar = create_session_jar(cookies, &state, &name).await?;
374
375    Ok((
376        jar,
377        LoggedInUser {
378            user: credentials.user.clone(),
379            is_admin: user.is_admin,
380            is_logged_in: true,
381        }
382        .into(),
383    ))
384}
385
386/// Get current login state
387#[utoipa::path(
388    get,
389    path = "/state",
390    tag = "auth",
391    responses(
392        (status = 200, description = "Current login state", body = LoggedInUser)
393    )
394)]
395#[expect(clippy::unused_async)] // part of the router
396pub async fn login_state(user: Option<SessionUser>) -> Json<LoggedInUser> {
397    match user {
398        Some(SessionUser(user)) => LoggedInUser {
399            user: user.name.into_inner(),
400            is_admin: user.is_admin,
401            is_logged_in: true,
402        },
403        None => LoggedInUser {
404            user: String::new(),
405            is_admin: false,
406            is_logged_in: false,
407        },
408    }
409    .into()
410}
411
412#[derive(Serialize, ToSchema)]
413pub struct LogoutResponse {
414    pub logout_url: Option<String>,
415}
416
417/// Logout and clear session
418#[utoipa::path(
419    post,
420    path = "/logout",
421    tag = "auth",
422    responses(
423        (status = 200, description = "Successfully logged out", body = LogoutResponse)
424    )
425)]
426pub async fn logout(
427    mut jar: PrivateCookieJar,
428    State(state): AppState,
429    Extension(oauth2_handler): OAuth2Ext,
430) -> Result<(PrivateCookieJar, Json<LogoutResponse>), ApiError> {
431    let session_id = match jar.get(COOKIE_SESSION_ID) {
432        Some(c) => c.value().to_owned(),
433        None => return Ok((jar, Json(LogoutResponse { logout_url: None }))),
434    };
435
436    let logout_url = jar
437        .get(COOKIE_OIDC_ID_TOKEN)
438        .zip(oauth2_handler.as_ref())
439        .and_then(|(id_token, handler)| {
440            let post_logout = format!("{}/", state.settings.origin.base_url());
441            handler.end_session_url(id_token.value(), &post_logout)
442        });
443
444    jar = jar.remove(Cookie::build((COOKIE_SESSION_ID, "")).path("/"));
445    jar = jar.remove(Cookie::build((COOKIE_SESSION_USER, "")).path("/"));
446    jar = jar.remove(Cookie::build((COOKIE_OIDC_ID_TOKEN, "")).path("/"));
447
448    // The browser drops the cookie even if the session cannot be deleted, so
449    // the jar must still be returned.
450    if let Err(e) = state
451        .db
452        .delete_session_token(&PlainToken::from(session_id))
453        .await
454    {
455        tracing::error!("Failed to delete session on logout: {e}");
456    }
457    Ok((jar, Json(LogoutResponse { logout_url })))
458}
459
460#[derive(Deserialize, ToSchema)]
461pub struct PwdChange {
462    pub old_pwd: String,
463    pub new_pwd1: String,
464    pub new_pwd2: String,
465}
466
467impl PwdChange {
468    pub fn validate(&self) -> Result<(), ApiError> {
469        if self.old_pwd.is_empty() {
470            return Err(ApiError::bad_request("The old password must not be empty"));
471        }
472        if self.new_pwd1.is_empty() || self.new_pwd2.is_empty() {
473            return Err(ApiError::bad_request("The new password must not be empty"));
474        }
475        if self.new_pwd1 != self.new_pwd2 {
476            return Err(ApiError::bad_request("The new passwords do not match"));
477        }
478        Ok(())
479    }
480}
481
482/// Change the current user's password
483#[utoipa::path(
484    put,
485    path = "/me/password",
486    tag = "users",
487    request_body = PwdChange,
488    responses(
489        (status = 200, description = "Password changed successfully"),
490        (status = 400, description = "Invalid password or validation failed"),
491        (status = 401, description = "Not authenticated")
492    ),
493    security(("session_cookie" = []))
494)]
495pub async fn change_pwd(
496    user: SessionUser,
497    jar: PrivateCookieJar,
498    State(db): DbState,
499    State(cache): TokenCacheState,
500    State(throttle): LoginThrottleState,
501    State(settings): SettingsState,
502    Json(pwd_change): Json<PwdChange>,
503) -> Result<(), ApiError> {
504    ensure_local_auth_enabled(&settings)?;
505    pwd_change.validate()?;
506
507    let user = authenticate_password(db.as_ref(), &throttle, &user.name, &pwd_change.old_pwd)
508        .await
509        .map_err(|e| match e {
510            PasswordAuthError::Throttled => ApiError::from(StatusCode::TOO_MANY_REQUESTS),
511            PasswordAuthError::Invalid => ApiError::bad_request("The old password is wrong"),
512        })?;
513
514    db.change_pwd(&user.name, &pwd_change.new_pwd1).await?;
515    let current_session = jar.get(COOKIE_SESSION_ID);
516    revoke_sessions(
517        db.as_ref(),
518        &user.name,
519        current_session.as_ref().map(Cookie::value),
520    )
521    .await?;
522
523    // Drop cached basic auth credentials carrying the old password.
524    cache.invalidate_all();
525    Ok(())
526}
527
528#[derive(Deserialize, ToSchema)]
529pub struct NewUser {
530    pub pwd1: String,
531    pub pwd2: String,
532    pub name: String,
533    #[serde(default)] // Set to false if not in message from client
534    pub is_admin: bool,
535    #[serde(default)] // Set to false if not in message from client
536    pub is_read_only: bool,
537}
538
539impl NewUser {
540    /// Returns the name of the new user.
541    pub fn validate(&self) -> Result<UserName, ApiError> {
542        let name = UserName::for_new_user(self.name.as_str())?;
543        if self.pwd1.is_empty() || self.pwd2.is_empty() {
544            return Err(ApiError::bad_request("The password must not be empty"));
545        }
546        if self.pwd1 != self.pwd2 {
547            return Err(ApiError::bad_request("The passwords do not match"));
548        }
549        Ok(name)
550    }
551}
552
553/// Create a new user (admin only)
554#[utoipa::path(
555    post,
556    path = "/",
557    tag = "users",
558    request_body = NewUser,
559    responses(
560        (status = 200, description = "User created successfully"),
561        (status = 400, description = "Validation failed"),
562        (status = 403, description = "Admin access required")
563    ),
564    security(("session_cookie" = []))
565)]
566pub async fn add(
567    _user: AdminUser,
568    State(db): DbState,
569    State(cache): TokenCacheState,
570    State(settings): SettingsState,
571    Json(new_user): Json<NewUser>,
572) -> Result<(), ApiError> {
573    ensure_local_auth_enabled(&settings)?;
574    let name = new_user.validate()?;
575
576    db.add_user(
577        &name,
578        &new_user.pwd1,
579        UserFlags {
580            admin: new_user.is_admin,
581            read_only: new_user.is_read_only,
582        },
583    )
584    .await?;
585
586    cache.invalidate_all();
587
588    Ok(())
589}
590
591#[cfg(test)]
592mod tests {
593    use std::sync::Arc;
594    use std::time::Duration;
595
596    use axum::Router;
597    use axum::body::Body;
598    use axum::http::Response;
599    use axum::response::IntoResponse;
600    use axum::routing::{post, put};
601    use hyper::{Method, Request, header};
602    use kellnr_db::test_utils::test_drop_table;
603    use kellnr_http::login_throttle::LoginThrottle;
604    use kellnr_http::state::AppStateData;
605    use kellnr_http::test_support::{encode_cookies, session_cookie, sqlite_test_state};
606    use kellnr_http::token_cache::{CachedTokenData, TokenCacheManager};
607    use kellnr_settings::constants::COOKIE_SESSION_ID;
608    use kellnr_test_db::TestDb;
609    use tower::ServiceExt;
610
611    use super::*;
612
613    /// The user routes at their paths in the API.
614    fn app(state: AppStateData) -> Router {
615        Router::new()
616            .route("/login", post(login))
617            .route("/logout", post(logout))
618            .route("/", post(add))
619            .route("/{name}", axum::routing::delete(delete))
620            .route("/{name}/password", put(reset_pwd))
621            .route("/{name}/admin", post(admin))
622            .route("/{name}/read-only", post(read_only))
623            .route("/me/password", put(change_pwd))
624            .route("/me/tokens", post(add_token))
625            .route("/me/tokens/{id}", axum::routing::delete(delete_token))
626            .layer(Extension::<Option<Arc<kellnr_oauth2::OAuth2Handler>>>(None))
627            .with_state(state)
628    }
629
630    fn sso_enforced(mut state: AppStateData) -> AppStateData {
631        let mut settings = (*state.settings).clone();
632        settings.oauth2.enabled = true;
633        settings.oauth2.enforced = true;
634        state.settings = Arc::new(settings);
635        state
636    }
637
638    fn token_cache() -> Arc<TokenCacheManager> {
639        Arc::new(TokenCacheManager::new(true, Duration::from_secs(60), 100))
640    }
641
642    /// A token cache that holds `key` for `user`.
643    async fn token_cache_with(key: &str, user: &str) -> Arc<TokenCacheManager> {
644        let cache = token_cache();
645        cache
646            .insert(
647                key.to_string(),
648                CachedTokenData {
649                    user: UserName::new(user).unwrap(),
650                    is_admin: false,
651                    is_read_only: false,
652                },
653                cache.generation(),
654            )
655            .await;
656        cache
657    }
658
659    async fn send(
660        state: AppStateData,
661        method: Method,
662        uri: &str,
663        cookie: &str,
664        json: Option<&str>,
665    ) -> Response<Body> {
666        let request = Request::builder()
667            .method(method)
668            .uri(uri)
669            .header(header::COOKIE, cookie)
670            .header(header::CONTENT_TYPE, "application/json");
671        let body = json.map_or_else(Body::empty, |json| Body::from(json.to_string()));
672        app(state)
673            .oneshot(request.body(body).unwrap())
674            .await
675            .unwrap()
676    }
677
678    async fn add_user(db: &TestDb, name: &str, flags: UserFlags) {
679        db.add_user(&UserName::new(name).unwrap(), "pwd", flags)
680            .await
681            .unwrap();
682    }
683
684    async fn has_password(db: &TestDb, name: &str, pwd: &str) -> bool {
685        db.authenticate_user(&UserName::new(name).unwrap(), pwd)
686            .await
687            .is_ok()
688    }
689
690    async fn user(db: &TestDb, name: &str) -> User {
691        db.get_user(&UserName::new(name).unwrap()).await.unwrap()
692    }
693
694    async fn has_session(db: &TestDb, token: &str) -> bool {
695        db.validate_session(&PlainToken::from(token)).await.is_ok()
696    }
697
698    async fn add_session(db: &TestDb, name: &str, token: &str) {
699        db.add_session_token(&UserName::new(name).unwrap(), &PlainToken::from(token))
700            .await
701            .unwrap();
702    }
703
704    // ==========================
705    // SSO Enforcement Tests
706    // ==========================
707
708    /// Password login must be refused outright, so that a still-valid local
709    /// password cannot be used to bypass SSO.
710    #[tokio::test]
711    async fn test_login_forbidden_when_sso_enforced() {
712        let (state, _db) = sqlite_test_state().await;
713
714        let response = send(
715            sso_enforced(state),
716            Method::POST,
717            "/login",
718            "",
719            Some(r#"{"user":"admin","pwd":"123"}"#),
720        )
721        .await;
722
723        assert_eq!(response.status(), StatusCode::FORBIDDEN);
724    }
725
726    /// Changing one's own password is meaningless under SSO and must not write
727    /// a new hash to the DB.
728    #[tokio::test]
729    async fn test_change_pwd_forbidden_when_sso_enforced() {
730        let (state, db) = sqlite_test_state().await;
731        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
732
733        let response = send(
734            sso_enforced(state),
735            Method::PUT,
736            "/me/password",
737            &cookie,
738            Some(r#"{"old_pwd":"pwd","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#),
739        )
740        .await;
741
742        assert_eq!(response.status(), StatusCode::FORBIDDEN);
743        assert!(has_password(&db, "user", "pwd").await);
744    }
745
746    /// An admin resetting another user's password must not produce a usable
747    /// local credential while SSO is the only permitted login method.
748    #[tokio::test]
749    async fn test_reset_pwd_forbidden_when_sso_enforced() {
750        let (state, db) = sqlite_test_state().await;
751        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
752        add_user(&db, "someuser", UserFlags::normal()).await;
753
754        let response = send(
755            sso_enforced(state),
756            Method::PUT,
757            "/someuser/password",
758            &cookie,
759            None,
760        )
761        .await;
762
763        assert_eq!(response.status(), StatusCode::FORBIDDEN);
764        assert!(has_password(&db, "someuser", "pwd").await);
765    }
766
767    /// Users are provisioned by the `IdP` under enforcement, so creating a local
768    /// password-backed account must be refused.
769    #[tokio::test]
770    async fn test_add_user_forbidden_when_sso_enforced() {
771        let (state, db) = sqlite_test_state().await;
772        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
773
774        let response = send(
775            sso_enforced(state),
776            Method::POST,
777            "/",
778            &cookie,
779            Some(
780                r#"{"name":"new_user","pwd1":"password","pwd2":"password","is_admin":false,"is_read_only":false}"#,
781            ),
782        )
783        .await;
784
785        assert_eq!(response.status(), StatusCode::FORBIDDEN);
786        assert!(
787            db.get_user(&UserName::new("new_user").unwrap())
788                .await
789                .is_err()
790        );
791    }
792
793    async fn post_login(state: &AppStateData, user: &str) -> StatusCode {
794        send(
795            state.clone(),
796            Method::POST,
797            "/login",
798            "",
799            Some(&format!(r#"{{"user":"{user}","pwd":"guess"}}"#)),
800        )
801        .await
802        .status()
803    }
804
805    /// Password guessing on the login form is throttled per username. Other
806    /// users stay unaffected so a single attacker cannot lock out everyone.
807    #[tokio::test]
808    async fn test_login_throttled_after_failed_attempts() {
809        let (state, db) = sqlite_test_state().await;
810        add_user(&db, "other", UserFlags::normal()).await;
811        let state = AppStateData {
812            login_throttle: Arc::new(LoginThrottle::new(2, Duration::from_mins(5))),
813            ..state
814        };
815
816        assert_eq!(post_login(&state, "admin").await, StatusCode::UNAUTHORIZED);
817        assert_eq!(post_login(&state, "admin").await, StatusCode::UNAUTHORIZED);
818        assert_eq!(
819            post_login(&state, "admin").await,
820            StatusCode::TOO_MANY_REQUESTS
821        );
822        assert_eq!(post_login(&state, "other").await, StatusCode::UNAUTHORIZED);
823    }
824
825    /// The old password check on password change must not become a way
826    /// around the login throttle.
827    #[tokio::test]
828    async fn test_change_pwd_throttled_after_failed_attempts() {
829        let (state, db) = sqlite_test_state().await;
830        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
831        let state = AppStateData {
832            login_throttle: Arc::new(LoginThrottle::new(1, Duration::from_mins(5))),
833            ..state
834        };
835
836        let mut statuses = Vec::new();
837        for _ in 0..2 {
838            let response = send(
839                state.clone(),
840                Method::PUT,
841                "/me/password",
842                &cookie,
843                Some(r#"{"old_pwd":"guess","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#),
844            )
845            .await;
846            statuses.push(response.status());
847        }
848
849        assert_eq!(
850            statuses,
851            [StatusCode::BAD_REQUEST, StatusCode::TOO_MANY_REQUESTS]
852        );
853        assert!(has_password(&db, "user", "pwd").await);
854    }
855
856    /// The removal cookie must carry the same path as the session cookie,
857    /// otherwise the browser keeps the original cookie after logout.
858    #[tokio::test]
859    async fn test_logout_removes_session_cookie_with_root_path() {
860        let (state, db) = sqlite_test_state().await;
861        add_session(&db, "admin", "session").await;
862
863        let response = send(
864            state,
865            Method::POST,
866            "/logout",
867            &encode_cookies([(COOKIE_SESSION_ID, "session")]),
868            None,
869        )
870        .await;
871
872        assert_eq!(response.status(), StatusCode::OK);
873        let removal = response
874            .headers()
875            .get_all(header::SET_COOKIE)
876            .iter()
877            .map(|v| v.to_str().unwrap().to_string())
878            .find(|c| c.starts_with(COOKIE_SESSION_ID))
879            .expect("session cookie must be removed");
880        assert!(removal.contains("Path=/"), "{removal}");
881        assert!(removal.contains("Max-Age=0"), "{removal}");
882        assert!(!has_session(&db, "session").await);
883    }
884
885    #[tokio::test]
886    async fn test_logout_removes_session_cookie_if_session_cannot_be_deleted() {
887        let (state, db) = sqlite_test_state().await;
888        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
889        test_drop_table(&db, "session").await.unwrap();
890
891        let response = send(state, Method::POST, "/logout", &cookie, None).await;
892
893        assert_eq!(response.status(), StatusCode::OK);
894        assert!(
895            response
896                .headers()
897                .get_all(header::SET_COOKIE)
898                .iter()
899                .any(|v| v.to_str().unwrap().starts_with(COOKIE_SESSION_ID))
900        );
901    }
902
903    fn new_user(name: &str) -> NewUser {
904        NewUser {
905            pwd1: "pwd".to_string(),
906            pwd2: "pwd".to_string(),
907            name: name.to_string(),
908            is_admin: false,
909            is_read_only: false,
910        }
911    }
912
913    /// Basic auth splits at the first ':', so such a user could never log in.
914    #[tokio::test]
915    async fn test_add_user_with_invalid_name_is_bad_request() {
916        let db = TestDb::sqlite().await;
917        for name in ["", "  ", "a:b", "line\nbreak", &"a".repeat(65)] {
918            let result = add(
919                AdminUser(kellnr_http::test_support::session_identity("admin", true)),
920                State(db.provider()),
921                State(token_cache()),
922                State(Arc::new(Settings::default())),
923                Json(new_user(name)),
924            )
925            .await;
926
927            assert!(
928                result
929                    .as_ref()
930                    .is_err_and(|e| e.status() == StatusCode::BAD_REQUEST),
931                "{name:?}: {result:?}"
932            );
933        }
934        assert_eq!(db.get_users().await.unwrap().len(), 1);
935    }
936
937    #[tokio::test]
938    async fn test_add_token_with_invalid_name_is_bad_request() {
939        let db = TestDb::sqlite().await;
940        let admin = UserName::new("admin").unwrap();
941        let tokens_before = db.get_auth_tokens(&admin).await.unwrap().len();
942        for name in [String::new(), "a".repeat(129)] {
943            let result = add_token(
944                SessionUser(kellnr_http::test_support::session_identity("admin", false)),
945                State(db.provider()),
946                State(token_cache()),
947                Json(token::NewTokenReqData { name }),
948            )
949            .await;
950
951            assert!(
952                result
953                    .as_ref()
954                    .is_err_and(|e| e.status() == StatusCode::BAD_REQUEST)
955            );
956        }
957        assert_eq!(
958            db.get_auth_tokens(&admin).await.unwrap().len(),
959            tokens_before
960        );
961    }
962
963    #[tokio::test]
964    async fn test_add_existing_user_is_conflict() {
965        let db = TestDb::sqlite().await;
966        let cache = token_cache();
967        let settings = Arc::new(Settings::default());
968        let add_bob = || {
969            add(
970                AdminUser(kellnr_http::test_support::session_identity("admin", true)),
971                State(db.provider()),
972                State(cache.clone()),
973                State(settings.clone()),
974                Json(new_user("bob")),
975            )
976        };
977
978        add_bob().await.unwrap();
979        let response = add_bob().await.unwrap_err().into_response();
980
981        assert_eq!(response.status(), StatusCode::CONFLICT);
982    }
983
984    const CHANGE_PWD: &str =
985        r#"{"old_pwd":"pwd","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#;
986
987    /// Cached basic auth credentials carry the old password and must not
988    /// survive a password change.
989    #[tokio::test]
990    async fn test_change_pwd_invalidates_cache() {
991        let (state, db) = sqlite_test_state().await;
992        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
993        let cache = token_cache_with("basic:credentials", "user").await;
994        let state = AppStateData {
995            token_cache: cache.clone(),
996            ..state
997        };
998
999        let response = send(
1000            state,
1001            Method::PUT,
1002            "/me/password",
1003            &cookie,
1004            Some(CHANGE_PWD),
1005        )
1006        .await;
1007
1008        assert_eq!(response.status(), StatusCode::OK);
1009        assert!(has_password(&db, "user", "newpassword").await);
1010        assert!(cache.get("basic:credentials").await.is_none());
1011    }
1012
1013    #[tokio::test]
1014    async fn test_reset_pwd_invalidates_cache() {
1015        let (state, db) = sqlite_test_state().await;
1016        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1017        add_user(&db, "user", UserFlags::normal()).await;
1018        let cache = token_cache_with("basic:credentials", "user").await;
1019        let state = AppStateData {
1020            token_cache: cache.clone(),
1021            ..state
1022        };
1023
1024        let response = send(state, Method::PUT, "/user/password", &cookie, None).await;
1025
1026        assert_eq!(response.status(), StatusCode::OK);
1027        assert!(!has_password(&db, "user", "pwd").await);
1028        assert!(cache.get("basic:credentials").await.is_none());
1029    }
1030
1031    /// Changing the password logs out every other session, for example one
1032    /// an attacker obtained with the old password, but keeps the current one.
1033    #[tokio::test]
1034    async fn test_change_pwd_revokes_other_sessions() {
1035        let (state, db) = sqlite_test_state().await;
1036        add_user(&db, "user", UserFlags::normal()).await;
1037        add_session(&db, "user", "current").await;
1038        add_session(&db, "user", "other").await;
1039
1040        let response = send(
1041            state,
1042            Method::PUT,
1043            "/me/password",
1044            &encode_cookies([(COOKIE_SESSION_ID, "current")]),
1045            Some(CHANGE_PWD),
1046        )
1047        .await;
1048
1049        assert_eq!(response.status(), StatusCode::OK);
1050        assert!(has_session(&db, "current").await);
1051        assert!(!has_session(&db, "other").await);
1052    }
1053
1054    /// An admin reset is typically a response to a compromised account, so
1055    /// all sessions of that user end.
1056    #[tokio::test]
1057    async fn test_reset_pwd_revokes_all_sessions_of_user() {
1058        let (state, db) = sqlite_test_state().await;
1059        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1060        add_user(&db, "user", UserFlags::normal()).await;
1061        add_session(&db, "user", "user-session").await;
1062
1063        let response = send(state, Method::PUT, "/user/password", &cookie, None).await;
1064
1065        assert_eq!(response.status(), StatusCode::OK);
1066        assert!(!has_session(&db, "user-session").await);
1067    }
1068
1069    #[tokio::test]
1070    async fn test_reset_own_pwd_keeps_current_session() {
1071        let (state, db) = sqlite_test_state().await;
1072        add_session(&db, "admin", "current").await;
1073        add_session(&db, "admin", "other").await;
1074
1075        let response = send(
1076            state,
1077            Method::PUT,
1078            "/admin/password",
1079            &encode_cookies([(COOKIE_SESSION_ID, "current")]),
1080            None,
1081        )
1082        .await;
1083
1084        assert_eq!(response.status(), StatusCode::OK);
1085        assert!(has_session(&db, "current").await);
1086        assert!(!has_session(&db, "other").await);
1087    }
1088
1089    #[tokio::test]
1090    async fn test_add_token_invalidates_cache() {
1091        let (state, db) = sqlite_test_state().await;
1092        let cookie = session_cookie(&db, "test_user", UserFlags::normal()).await;
1093        let cache = token_cache_with("existing_token", "test_user").await;
1094        let state = AppStateData {
1095            token_cache: cache.clone(),
1096            ..state
1097        };
1098
1099        let response = send(
1100            state,
1101            Method::POST,
1102            "/me/tokens",
1103            &cookie,
1104            Some(r#"{"name":"new_token"}"#),
1105        )
1106        .await;
1107
1108        assert_eq!(response.status(), StatusCode::OK);
1109        let tokens = db
1110            .get_auth_tokens(&UserName::new("test_user").unwrap())
1111            .await
1112            .unwrap();
1113        assert_eq!(
1114            tokens.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
1115            ["new_token"]
1116        );
1117        assert!(cache.get("existing_token").await.is_none());
1118    }
1119
1120    /// Adds an API token for `user` and returns its id.
1121    async fn add_auth_token(db: &TestDb, user: &str) -> AuthTokenId {
1122        let user = UserName::new(user).unwrap();
1123        db.add_auth_token("ci", &PlainToken::from(format!("{user}-token")), &user)
1124            .await
1125            .unwrap();
1126        db.get_auth_tokens(&user).await.unwrap()[0].id
1127    }
1128
1129    #[tokio::test]
1130    async fn test_delete_token_invalidates_cache() {
1131        let (state, db) = sqlite_test_state().await;
1132        let cookie = session_cookie(&db, "test_user", UserFlags::normal()).await;
1133        let id = add_auth_token(&db, "test_user").await;
1134        let cache = token_cache_with("token_to_keep", "test_user").await;
1135        let state = AppStateData {
1136            token_cache: cache.clone(),
1137            ..state
1138        };
1139
1140        let response = send(
1141            state,
1142            Method::DELETE,
1143            &format!("/me/tokens/{id}"),
1144            &cookie,
1145            None,
1146        )
1147        .await;
1148
1149        assert_eq!(response.status(), StatusCode::OK);
1150        let user = UserName::new("test_user").unwrap();
1151        assert!(db.get_auth_tokens(&user).await.unwrap().is_empty());
1152        assert!(cache.get("token_to_keep").await.is_none());
1153    }
1154
1155    #[tokio::test]
1156    async fn test_delete_token_of_other_user_is_not_found() {
1157        let (state, db) = sqlite_test_state().await;
1158        let cookie = session_cookie(&db, "test_user", UserFlags::normal()).await;
1159        add_user(&db, "other", UserFlags::normal()).await;
1160        let id = add_auth_token(&db, "other").await;
1161
1162        let response = send(
1163            state,
1164            Method::DELETE,
1165            &format!("/me/tokens/{id}"),
1166            &cookie,
1167            None,
1168        )
1169        .await;
1170
1171        assert_eq!(StatusCode::NOT_FOUND, response.status());
1172        let other = UserName::new("other").unwrap();
1173        assert_eq!(db.get_auth_tokens(&other).await.unwrap().len(), 1);
1174    }
1175
1176    #[tokio::test]
1177    async fn test_delete_user_invalidates_cache() {
1178        let (state, db) = sqlite_test_state().await;
1179        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1180        add_user(&db, "user_to_delete", UserFlags::normal()).await;
1181        let cache = token_cache_with("user_token", "user_to_delete").await;
1182        let state = AppStateData {
1183            token_cache: cache.clone(),
1184            ..state
1185        };
1186
1187        let response = send(state, Method::DELETE, "/user_to_delete", &cookie, None).await;
1188
1189        assert_eq!(response.status(), StatusCode::OK);
1190        let name = UserName::new("user_to_delete").unwrap();
1191        assert!(db.get_user(&name).await.is_err());
1192        assert!(cache.get("user_token").await.is_none());
1193    }
1194
1195    #[tokio::test]
1196    async fn test_read_only_change_invalidates_cache() {
1197        let (state, db) = sqlite_test_state().await;
1198        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1199        add_user(&db, "target_user", UserFlags::normal()).await;
1200        let cache = token_cache_with("user_token", "target_user").await;
1201        let state = AppStateData {
1202            token_cache: cache.clone(),
1203            ..state
1204        };
1205
1206        let response = send(
1207            state,
1208            Method::POST,
1209            "/target_user/read-only",
1210            &cookie,
1211            Some(r#"{"state":true}"#),
1212        )
1213        .await;
1214
1215        assert_eq!(response.status(), StatusCode::OK);
1216        assert!(user(&db, "target_user").await.is_read_only);
1217        // The cached permissions are outdated now.
1218        assert!(cache.get("user_token").await.is_none());
1219    }
1220
1221    #[tokio::test]
1222    async fn test_admin_self_locking_prevented() {
1223        let (state, db) = sqlite_test_state().await;
1224        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1225
1226        let response = send(
1227            state,
1228            Method::POST,
1229            "/admin/read-only",
1230            &cookie,
1231            Some(r#"{"state":true}"#),
1232        )
1233        .await;
1234
1235        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1236        assert!(!user(&db, "admin").await.is_read_only);
1237    }
1238
1239    /// Only locking oneself is blocked, unlocking is allowed.
1240    #[tokio::test]
1241    async fn test_admin_self_unlocking_allowed() {
1242        let (state, db) = sqlite_test_state().await;
1243        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1244
1245        let response = send(
1246            state,
1247            Method::POST,
1248            "/admin/read-only",
1249            &cookie,
1250            Some(r#"{"state":false}"#),
1251        )
1252        .await;
1253
1254        assert_eq!(response.status(), StatusCode::OK);
1255        assert!(!user(&db, "admin").await.is_read_only);
1256    }
1257
1258    #[tokio::test]
1259    async fn test_admin_locking_other_user_works() {
1260        let (state, db) = sqlite_test_state().await;
1261        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1262        add_user(&db, "other_user", UserFlags::normal()).await;
1263
1264        let response = send(
1265            state,
1266            Method::POST,
1267            "/other_user/read-only",
1268            &cookie,
1269            Some(r#"{"state":true}"#),
1270        )
1271        .await;
1272
1273        assert_eq!(response.status(), StatusCode::OK);
1274        assert!(user(&db, "other_user").await.is_read_only);
1275    }
1276
1277    #[tokio::test]
1278    async fn test_add_user_invalidates_cache() {
1279        let (state, db) = sqlite_test_state().await;
1280        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1281        let cache = token_cache_with("existing_token", "existing_user").await;
1282        let state = AppStateData {
1283            token_cache: cache.clone(),
1284            ..state
1285        };
1286
1287        let response = send(
1288            state,
1289            Method::POST,
1290            "/",
1291            &cookie,
1292            Some(
1293                r#"{"name":"new_user","pwd1":"password","pwd2":"password","is_admin":false,"is_read_only":false}"#,
1294            ),
1295        )
1296        .await;
1297
1298        assert_eq!(response.status(), StatusCode::OK);
1299        assert!(has_password(&db, "new_user", "password").await);
1300        assert!(cache.get("existing_token").await.is_none());
1301    }
1302
1303    #[tokio::test]
1304    async fn test_cache_not_invalidated_on_db_failure() {
1305        let (state, db) = sqlite_test_state().await;
1306        let cookie = session_cookie(&db, "test_user", UserFlags::normal()).await;
1307        test_drop_table(&db, "auth_token").await.unwrap();
1308        let cache = token_cache_with("existing_token", "test_user").await;
1309        let state = AppStateData {
1310            token_cache: cache.clone(),
1311            ..state
1312        };
1313
1314        let response = send(
1315            state,
1316            Method::POST,
1317            "/me/tokens",
1318            &cookie,
1319            Some(r#"{"name":"new_token"}"#),
1320        )
1321        .await;
1322
1323        assert!(!response.status().is_success());
1324        assert!(cache.get("existing_token").await.is_some());
1325    }
1326
1327    #[tokio::test]
1328    async fn test_admin_change_invalidates_cache() {
1329        let (state, db) = sqlite_test_state().await;
1330        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1331        add_user(&db, "target_user", UserFlags::normal()).await;
1332        let cache = token_cache_with("user_token", "target_user").await;
1333        let state = AppStateData {
1334            token_cache: cache.clone(),
1335            ..state
1336        };
1337
1338        let response = send(
1339            state,
1340            Method::POST,
1341            "/target_user/admin",
1342            &cookie,
1343            Some(r#"{"state":true}"#),
1344        )
1345        .await;
1346
1347        assert_eq!(response.status(), StatusCode::OK);
1348        assert!(user(&db, "target_user").await.is_admin);
1349        // The cached permissions are outdated now.
1350        assert!(cache.get("user_token").await.is_none());
1351    }
1352
1353    #[tokio::test]
1354    async fn test_admin_self_demotion_prevented() {
1355        let (state, db) = sqlite_test_state().await;
1356        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1357
1358        let response = send(
1359            state,
1360            Method::POST,
1361            "/admin/admin",
1362            &cookie,
1363            Some(r#"{"state":false}"#),
1364        )
1365        .await;
1366
1367        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1368        assert!(user(&db, "admin").await.is_admin);
1369    }
1370
1371    #[tokio::test]
1372    async fn test_non_admin_cannot_change_admin_status() {
1373        let (state, db) = sqlite_test_state().await;
1374        let cookie = session_cookie(&db, "regular_user", UserFlags::normal()).await;
1375        add_user(&db, "target_user", UserFlags::normal()).await;
1376
1377        let response = send(
1378            state,
1379            Method::POST,
1380            "/target_user/admin",
1381            &cookie,
1382            Some(r#"{"state":true}"#),
1383        )
1384        .await;
1385
1386        assert_eq!(response.status(), StatusCode::FORBIDDEN);
1387        assert!(!user(&db, "target_user").await.is_admin);
1388    }
1389
1390    #[tokio::test]
1391    async fn test_admin_demotion_works() {
1392        let (state, db) = sqlite_test_state().await;
1393        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1394        add_user(&db, "other_admin", UserFlags::admin()).await;
1395
1396        let response = send(
1397            state,
1398            Method::POST,
1399            "/other_admin/admin",
1400            &cookie,
1401            Some(r#"{"state":false}"#),
1402        )
1403        .await;
1404
1405        assert_eq!(response.status(), StatusCode::OK);
1406        assert!(!user(&db, "other_admin").await.is_admin);
1407    }
1408
1409    #[tokio::test]
1410    async fn test_admin_nonexistent_user_returns_error() {
1411        let (state, db) = sqlite_test_state().await;
1412        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1413
1414        let response = send(
1415            state,
1416            Method::POST,
1417            "/nonexistent/admin",
1418            &cookie,
1419            Some(r#"{"state":true}"#),
1420        )
1421        .await;
1422
1423        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1424    }
1425
1426    /// Only demoting oneself is blocked, promoting is a harmless no-op.
1427    #[tokio::test]
1428    async fn test_admin_self_promotion_allowed() {
1429        let (state, db) = sqlite_test_state().await;
1430        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1431
1432        let response = send(
1433            state,
1434            Method::POST,
1435            "/admin/admin",
1436            &cookie,
1437            Some(r#"{"state":true}"#),
1438        )
1439        .await;
1440
1441        assert_eq!(response.status(), StatusCode::OK);
1442        assert!(user(&db, "admin").await.is_admin);
1443    }
1444
1445    #[tokio::test]
1446    async fn test_admin_self_deletion_prevented() {
1447        let (state, db) = sqlite_test_state().await;
1448        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1449
1450        let response = send(state, Method::DELETE, "/admin", &cookie, None).await;
1451
1452        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1453        user(&db, "admin").await;
1454    }
1455
1456    #[tokio::test]
1457    async fn test_admin_deletion_of_other_user_works() {
1458        let (state, db) = sqlite_test_state().await;
1459        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1460        add_user(&db, "other_user", UserFlags::normal()).await;
1461
1462        let response = send(state, Method::DELETE, "/other_user", &cookie, None).await;
1463
1464        assert_eq!(response.status(), StatusCode::OK);
1465        let name = UserName::new("other_user").unwrap();
1466        assert!(db.get_user(&name).await.is_err());
1467    }
1468
1469    /// Names stored before the name rules existed must still be usable.
1470    #[tokio::test]
1471    async fn test_admin_deletion_of_user_with_name_against_the_rules_works() {
1472        let legacy_name = "legacy:user";
1473        assert!(UserName::for_new_user(legacy_name).is_err());
1474        let (state, db) = sqlite_test_state().await;
1475        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1476        add_user(&db, legacy_name, UserFlags::normal()).await;
1477
1478        let response = send(
1479            state,
1480            Method::DELETE,
1481            &format!("/{legacy_name}"),
1482            &cookie,
1483            None,
1484        )
1485        .await;
1486
1487        assert_eq!(response.status(), StatusCode::OK);
1488        let name = UserName::new(legacy_name).unwrap();
1489        assert!(db.get_user(&name).await.is_err());
1490    }
1491}