mas_handlers/admin/v1/users/
deactivate.rs1use aide::{NoApi, OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use hyper::StatusCode;
10use mas_storage::{
11 BoxRng,
12 queue::{DeactivateUserJob, QueueJobRepositoryExt as _},
13};
14use tracing::info;
15use ulid::Ulid;
16
17use crate::{
18 admin::{
19 call_context::CallContext,
20 model::{Resource, User},
21 params::UlidPathParam,
22 response::{ErrorResponse, SingleResponse},
23 },
24 impl_from_error_for_route,
25};
26
27#[derive(Debug, thiserror::Error, OperationIo)]
28#[aide(output_with = "Json<ErrorResponse>")]
29pub enum RouteError {
30 #[error(transparent)]
31 Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
32
33 #[error("User ID {0} not found")]
34 NotFound(Ulid),
35}
36
37impl_from_error_for_route!(mas_storage::RepositoryError);
38
39impl IntoResponse for RouteError {
40 fn into_response(self) -> axum::response::Response {
41 let error = ErrorResponse::from_error(&self);
42 let status = match self {
43 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
44 Self::NotFound(_) => StatusCode::NOT_FOUND,
45 };
46 (status, Json(error)).into_response()
47 }
48}
49
50pub fn doc(operation: TransformOperation) -> TransformOperation {
51 operation
52 .id("deactivateUser")
53 .summary("Deactivate a user")
54 .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action.
55This invalidates any existing session, and will ask the homeserver to make them leave all rooms.")
56 .tag("user")
57 .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
58 let [_alice, _bob, charlie, ..] = User::samples();
60 let id = charlie.id();
61 let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate"));
62 t.description("User was deactivated").example(response)
63 })
64 .response_with::<404, RouteError, _>(|t| {
65 let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
66 t.description("User ID not found").example(response)
67 })
68}
69
70#[tracing::instrument(name = "handler.admin.v1.users.deactivate", skip_all, err)]
71pub async fn handler(
72 CallContext {
73 mut repo, clock, ..
74 }: CallContext,
75 NoApi(mut rng): NoApi<BoxRng>,
76 id: UlidPathParam,
77) -> Result<Json<SingleResponse<User>>, RouteError> {
78 let id = *id;
79 let mut user = repo
80 .user()
81 .lookup(id)
82 .await?
83 .ok_or(RouteError::NotFound(id))?;
84
85 if user.locked_at.is_none() {
86 user = repo.user().lock(&clock, user).await?;
87 }
88
89 info!("Scheduling deactivation of user {}", user.id);
90 repo.queue_job()
91 .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true))
92 .await?;
93
94 repo.save().await?;
95
96 Ok(Json(SingleResponse::new(
97 User::from(user),
98 format!("/api/admin/v1/users/{id}/deactivate"),
99 )))
100}
101
102#[cfg(test)]
103mod tests {
104 use chrono::Duration;
105 use hyper::{Request, StatusCode};
106 use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
107 use sqlx::{PgPool, types::Json};
108
109 use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
110
111 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
112 async fn test_deactivate_user(pool: PgPool) {
113 setup();
114 let mut state = TestState::from_pool(pool.clone()).await.unwrap();
115 let token = state.token_with_scope("urn:mas:admin").await;
116
117 let mut repo = state.repository().await.unwrap();
118 let user = repo
119 .user()
120 .add(&mut state.rng(), &state.clock, "alice".to_owned())
121 .await
122 .unwrap();
123 repo.save().await.unwrap();
124
125 let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
126 .bearer(&token)
127 .empty();
128 let response = state.request(request).await;
129 response.assert_status(StatusCode::OK);
130 let body: serde_json::Value = response.json();
131
132 assert_eq!(
134 body["data"]["attributes"]["locked_at"],
135 serde_json::json!(state.clock.now())
136 );
137
138 let job: Json<serde_json::Value> = sqlx::query_scalar(
141 "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
142 )
143 .fetch_one(&pool)
144 .await
145 .expect("Deactivation job to be scheduled");
146 assert_eq!(job["user_id"], serde_json::json!(user.id));
147 }
148
149 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
150 async fn test_deactivate_locked_user(pool: PgPool) {
151 setup();
152 let mut state = TestState::from_pool(pool.clone()).await.unwrap();
153 let token = state.token_with_scope("urn:mas:admin").await;
154
155 let mut repo = state.repository().await.unwrap();
156 let user = repo
157 .user()
158 .add(&mut state.rng(), &state.clock, "alice".to_owned())
159 .await
160 .unwrap();
161 let user = repo.user().lock(&state.clock, user).await.unwrap();
162 repo.save().await.unwrap();
163
164 state.clock.advance(Duration::try_minutes(1).unwrap());
166
167 let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
168 .bearer(&token)
169 .empty();
170 let response = state.request(request).await;
171 response.assert_status(StatusCode::OK);
172 let body: serde_json::Value = response.json();
173
174 assert_ne!(
176 body["data"]["attributes"]["locked_at"],
177 serde_json::json!(state.clock.now())
178 );
179
180 let job: Json<serde_json::Value> = sqlx::query_scalar(
183 "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
184 )
185 .fetch_one(&pool)
186 .await
187 .expect("Deactivation job to be scheduled");
188 assert_eq!(job["user_id"], serde_json::json!(user.id));
189 }
190
191 #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
192 async fn test_deactivate_unknown_user(pool: PgPool) {
193 setup();
194 let mut state = TestState::from_pool(pool).await.unwrap();
195 let token = state.token_with_scope("urn:mas:admin").await;
196
197 let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/deactivate")
198 .bearer(&token)
199 .empty();
200 let response = state.request(request).await;
201 response.assert_status(StatusCode::NOT_FOUND);
202 let body: serde_json::Value = response.json();
203 assert_eq!(
204 body["errors"][0]["title"],
205 "User ID 01040G2081040G2081040G2081 not found"
206 );
207 }
208}