mas_handlers/admin/v1/users/
lock.rs1use aide::{OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use hyper::StatusCode;
10use ulid::Ulid;
11
12use crate::{
13 admin::{
14 call_context::CallContext,
15 model::{Resource, User},
16 params::UlidPathParam,
17 response::{ErrorResponse, SingleResponse},
18 },
19 impl_from_error_for_route,
20};
21
22#[derive(Debug, thiserror::Error, OperationIo)]
23#[aide(output_with = "Json<ErrorResponse>")]
24pub enum RouteError {
25 #[error(transparent)]
26 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
27
28 #[error("User ID {0} not found")]
29 NotFound(Ulid),
30}
31
32impl_from_error_for_route!(mas_storage::RepositoryError);
33
34impl IntoResponse for RouteError {
35 fn into_response(self) -> axum::response::Response {
36 let error = ErrorResponse::from_error(&self);
37 let status = match self {
38 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
39 Self::NotFound(_) => StatusCode::NOT_FOUND,
40 };
41 (status, Json(error)).into_response()
42 }
43}
44
45pub fn doc(operation: TransformOperation) -> TransformOperation {
46 operation
47 .id("lockUser")
48 .summary("Lock a user")
49 .description("Calling this endpoint will lock the user, preventing them from doing any action.
50This DOES NOT invalidate any existing session, meaning that all their existing sessions will work again as soon as they get unlocked.")
51 .tag("user")
52 .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
53 let [_alice, _bob, charlie, ..] = User::samples();
55 let id = charlie.id();
56 let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/lock"));
57 t.description("User was locked").example(response)
58 })
59 .response_with::<404, RouteError, _>(|t| {
60 let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
61 t.description("User ID not found").example(response)
62 })
63}
64
65#[tracing::instrument(name = "handler.admin.v1.users.lock", skip_all, err)]
66pub async fn handler(
67 CallContext {
68 mut repo, clock, ..
69 }: CallContext,
70 id: UlidPathParam,
71) -> Result<Json<SingleResponse<User>>, RouteError> {
72 let id = *id;
73 let mut user = repo
74 .user()
75 .lookup(id)
76 .await?
77 .ok_or(RouteError::NotFound(id))?;
78
79 if user.locked_at.is_none() {
80 user = repo.user().lock(&clock, user).await?;
81 }
82
83 repo.save().await?;
84
85 Ok(Json(SingleResponse::new(
86 User::from(user),
87 format!("/api/admin/v1/users/{id}/lock"),
88 )))
89}
90
91#[cfg(test)]
92mod tests {
93 use chrono::Duration;
94 use hyper::{Request, StatusCode};
95 use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
96 use sqlx::PgPool;
97
98 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
99
100 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
101 async fn test_lock_user(pool: PgPool) {
102 setup();
103 let mut state = TestState::from_pool(pool).await.unwrap();
104 let token = state.token_with_scope("urn:mas:admin").await;
105
106 let mut repo = state.repository().await.unwrap();
107 let user = repo
108 .user()
109 .add(&mut state.rng(), &state.clock, "alice".to_owned())
110 .await
111 .unwrap();
112 repo.save().await.unwrap();
113
114 let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id))
115 .bearer(&token)
116 .empty();
117 let response = state.request(request).await;
118 response.assert_status(StatusCode::OK);
119 let body: serde_json::Value = response.json();
120
121 assert_eq!(
123 body["data"]["attributes"]["locked_at"],
124 serde_json::json!(state.clock.now())
125 );
126 }
127
128 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
129 async fn test_lock_user_twice(pool: PgPool) {
130 setup();
131 let mut state = TestState::from_pool(pool).await.unwrap();
132 let token = state.token_with_scope("urn:mas:admin").await;
133
134 let mut repo = state.repository().await.unwrap();
135 let user = repo
136 .user()
137 .add(&mut state.rng(), &state.clock, "alice".to_owned())
138 .await
139 .unwrap();
140 let user = repo.user().lock(&state.clock, user).await.unwrap();
141 repo.save().await.unwrap();
142
143 state.clock.advance(Duration::try_minutes(1).unwrap());
145
146 let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id))
147 .bearer(&token)
148 .empty();
149 let response = state.request(request).await;
150 response.assert_status(StatusCode::OK);
151 let body: serde_json::Value = response.json();
152
153 assert_ne!(
155 body["data"]["attributes"]["locked_at"],
156 serde_json::json!(state.clock.now())
157 );
158 }
159
160 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
161 async fn test_lock_unknown_user(pool: PgPool) {
162 setup();
163 let mut state = TestState::from_pool(pool).await.unwrap();
164 let token = state.token_with_scope("urn:mas:admin").await;
165
166 let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/lock")
167 .bearer(&token)
168 .empty();
169 let response = state.request(request).await;
170 response.assert_status(StatusCode::NOT_FOUND);
171 let body: serde_json::Value = response.json();
172 assert_eq!(
173 body["errors"][0]["title"],
174 "User ID 01040G2081040G2081040G2081 not found"
175 );
176 }
177}