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