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