Skip to content

Commit 6c1afee

Browse files
committed
Separate active state from lock state in admin API
- Allow the admin API to deactivate a user without locking it, and to unlock a user without reactivating it. - Make unlock-and-reactivate flows unset the "deactivated_at" timestamp. - Revert adding an "unlock" parameter on `ReactivateUserJob`, as the option is used only by the admin API which doesn't use a job.
1 parent 13a21cc commit 6c1afee

File tree

11 files changed

+266
-111
lines changed

11 files changed

+266
-111
lines changed

crates/cli/src/commands/manage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ impl Options {
542542

543543
warn!(%user.id, "User scheduling user reactivation");
544544
repo.queue_job()
545-
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user, true))
545+
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user))
546546
.await?;
547547

548548
repo.into_inner().commit().await?;

crates/handlers/src/admin/v1/users/deactivate.rs

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use mas_storage::{
1212
BoxRng,
1313
queue::{DeactivateUserJob, QueueJobRepositoryExt as _},
1414
};
15+
use schemars::JsonSchema;
16+
use serde::Deserialize;
1517
use tracing::info;
1618
use ulid::Ulid;
1719

@@ -49,7 +51,25 @@ impl IntoResponse for RouteError {
4951
}
5052
}
5153

52-
pub fn doc(operation: TransformOperation) -> TransformOperation {
54+
/// # JSON payload for the `POST /api/admin/v1/users/:id/deactivate` endpoint
55+
#[derive(Default, Deserialize, JsonSchema)]
56+
#[serde(rename = "DeactivateUserRequest")]
57+
pub struct Request {
58+
/// Whether to skip locking the user before deactivation.
59+
#[serde(default)]
60+
skip_lock: bool,
61+
}
62+
63+
pub fn doc(mut operation: TransformOperation) -> TransformOperation {
64+
operation
65+
.inner_mut()
66+
.request_body
67+
.as_mut()
68+
.unwrap()
69+
.as_item_mut()
70+
.unwrap()
71+
.required = false;
72+
5373
operation
5474
.id("deactivateUser")
5575
.summary("Deactivate a user")
@@ -76,15 +96,17 @@ pub async fn handler(
7696
}: CallContext,
7797
NoApi(mut rng): NoApi<BoxRng>,
7898
id: UlidPathParam,
99+
body: Option<Json<Request>>,
79100
) -> Result<Json<SingleResponse<User>>, RouteError> {
101+
let Json(params) = body.unwrap_or_default();
80102
let id = *id;
81103
let mut user = repo
82104
.user()
83105
.lookup(id)
84106
.await?
85107
.ok_or(RouteError::NotFound(id))?;
86108

87-
if user.locked_at.is_none() {
109+
if !params.skip_lock && user.locked_at.is_none() {
88110
user = repo.user().lock(&clock, user).await?;
89111
}
90112

@@ -105,14 +127,13 @@ pub async fn handler(
105127
mod tests {
106128
use chrono::Duration;
107129
use hyper::{Request, StatusCode};
108-
use insta::assert_json_snapshot;
130+
use insta::{allow_duplicates, assert_json_snapshot};
109131
use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
110132
use sqlx::PgPool;
111133

112134
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
113135

114-
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
115-
async fn test_deactivate_user(pool: PgPool) {
136+
async fn test_deactivate_user_helper(pool: PgPool, skip_lock: Option<bool>) {
116137
setup();
117138
let mut state = TestState::from_pool(pool.clone()).await.unwrap();
118139
let token = state.token_with_scope("urn:mas:admin").await;
@@ -125,19 +146,27 @@ mod tests {
125146
.unwrap();
126147
repo.save().await.unwrap();
127148

128-
let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
129-
.bearer(&token)
130-
.empty();
149+
let request =
150+
Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)).bearer(&token);
151+
let request = match skip_lock {
152+
None => request.empty(),
153+
Some(skip_lock) => request.json(serde_json::json!({
154+
"skip_lock": skip_lock,
155+
})),
156+
};
131157
let response = state.request(request).await;
132158
response.assert_status(StatusCode::OK);
133159
let body: serde_json::Value = response.json();
134160

135-
// The locked_at timestamp should be the same as the current time
161+
// The locked_at timestamp should be the same as the current time, or null if not locked
136162
assert_eq!(
137163
body["data"]["attributes"]["locked_at"],
138-
serde_json::json!(state.clock.now())
164+
if !skip_lock.unwrap_or(false) {
165+
serde_json::json!(state.clock.now())
166+
} else {
167+
serde_json::Value::Null
168+
}
139169
);
140-
// TODO: have test coverage on deactivated_at timestamp
141170

142171
// Make sure to run the jobs in the queue
143172
state.run_jobs_in_queue().await;
@@ -149,7 +178,7 @@ mod tests {
149178
response.assert_status(StatusCode::OK);
150179
let body: serde_json::Value = response.json();
151180

152-
assert_json_snapshot!(body, @r#"
181+
allow_duplicates!(assert_json_snapshot!(body, @r#"
153182
{
154183
"data": {
155184
"type": "user",
@@ -169,7 +198,17 @@ mod tests {
169198
"self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E"
170199
}
171200
}
172-
"#);
201+
"#));
202+
}
203+
204+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
205+
async fn test_deactivate_user(pool: PgPool) {
206+
test_deactivate_user_helper(pool, Option::None).await;
207+
}
208+
209+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
210+
async fn test_deactivate_user_skip_lock(pool: PgPool) {
211+
test_deactivate_user_helper(pool, Option::Some(true)).await;
173212
}
174213

175214
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -206,7 +245,6 @@ mod tests {
206245
body["data"]["attributes"]["locked_at"],
207246
serde_json::Value::Null
208247
);
209-
// TODO: have test coverage on deactivated_at timestamp
210248

211249
// Make sure to run the jobs in the queue
212250
state.run_jobs_in_queue().await;

crates/handlers/src/admin/v1/users/reactivate.rs

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@
33
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
44
// Please see LICENSE files in the repository root for full details.
55

6-
use aide::{NoApi, OperationIo, transform::TransformOperation};
7-
use axum::{Json, response::IntoResponse};
6+
use std::sync::Arc;
7+
8+
use aide::{OperationIo, transform::TransformOperation};
9+
use axum::{Json, extract::State, response::IntoResponse};
810
use hyper::StatusCode;
911
use mas_axum_utils::record_error;
10-
use mas_storage::{
11-
BoxRng,
12-
queue::{QueueJobRepositoryExt as _, ReactivateUserJob},
13-
};
14-
use tracing::info;
12+
use mas_matrix::HomeserverConnection;
1513
use ulid::Ulid;
1614

1715
use crate::{
@@ -30,6 +28,9 @@ pub enum RouteError {
3028
#[error(transparent)]
3129
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
3230

31+
#[error(transparent)]
32+
Homeserver(anyhow::Error),
33+
3334
#[error("User ID {0} not found")]
3435
NotFound(Ulid),
3536
}
@@ -39,9 +40,9 @@ impl_from_error_for_route!(mas_storage::RepositoryError);
3940
impl IntoResponse for RouteError {
4041
fn into_response(self) -> axum::response::Response {
4142
let error = ErrorResponse::from_error(&self);
42-
let sentry_event_id = record_error!(self, Self::Internal(_));
43+
let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_));
4344
let status = match self {
44-
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
45+
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
4546
Self::NotFound(_) => StatusCode::NOT_FOUND,
4647
};
4748
(status, sentry_event_id, Json(error)).into_response()
@@ -69,10 +70,8 @@ pub fn doc(operation: TransformOperation) -> TransformOperation {
6970

7071
#[tracing::instrument(name = "handler.admin.v1.users.reactivate", skip_all)]
7172
pub async fn handler(
72-
CallContext {
73-
mut repo, clock, ..
74-
}: CallContext,
75-
NoApi(mut rng): NoApi<BoxRng>,
73+
CallContext { mut repo, .. }: CallContext,
74+
State(homeserver): State<Arc<dyn HomeserverConnection>>,
7675
id: UlidPathParam,
7776
) -> Result<Json<SingleResponse<User>>, RouteError> {
7877
let id = *id;
@@ -82,10 +81,15 @@ pub async fn handler(
8281
.await?
8382
.ok_or(RouteError::NotFound(id))?;
8483

85-
info!(%user.id, "Scheduling reactivation of user");
86-
repo.queue_job()
87-
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user, false))
88-
.await?;
84+
// Call the homeserver synchronously to reactivate the user
85+
let mxid = homeserver.mxid(&user.username);
86+
homeserver
87+
.reactivate_user(&mxid)
88+
.await
89+
.map_err(RouteError::Homeserver)?;
90+
91+
// Now reactivate the user in our database
92+
let user = repo.user().reactivate(user).await?;
8993

9094
repo.save().await?;
9195

@@ -100,7 +104,7 @@ mod tests {
100104
use hyper::{Request, StatusCode};
101105
use mas_matrix::{HomeserverConnection, ProvisionRequest};
102106
use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
103-
use sqlx::{PgPool, types::Json};
107+
use sqlx::PgPool;
104108

105109
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
106110

@@ -150,18 +154,10 @@ mod tests {
150154
body["data"]["attributes"]["locked_at"],
151155
serde_json::json!(state.clock.now())
152156
);
153-
// TODO: have test coverage on deactivated_at timestamp
154-
155-
// It should have scheduled a reactivation job for the user
156-
// XXX: we don't have a good way to look for the reactivation job
157-
let job: Json<serde_json::Value> = sqlx::query_scalar(
158-
"SELECT payload FROM queue_jobs WHERE queue_name = 'reactivate-user'",
159-
)
160-
.fetch_one(&pool)
161-
.await
162-
.expect("Reactivation job to be scheduled");
163-
assert_eq!(job["user_id"], serde_json::json!(user.id));
164-
assert_eq!(job["unlock"], serde_json::Value::Bool(false));
157+
assert_eq!(
158+
body["data"]["attributes"]["deactivated_at"],
159+
serde_json::Value::Null,
160+
);
165161
}
166162

167163
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -178,6 +174,14 @@ mod tests {
178174
.unwrap();
179175
repo.save().await.unwrap();
180176

177+
// Provision the user on the homeserver
178+
let mxid = state.homeserver_connection.mxid(&user.username);
179+
state
180+
.homeserver_connection
181+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
182+
.await
183+
.unwrap();
184+
181185
let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id))
182186
.bearer(&token)
183187
.empty();
@@ -189,18 +193,10 @@ mod tests {
189193
body["data"]["attributes"]["locked_at"],
190194
serde_json::Value::Null
191195
);
192-
// TODO: have test coverage on deactivated_at timestamp
193-
194-
// It should have scheduled a reactivation job for the user
195-
// XXX: we don't have a good way to look for the reactivation job
196-
let job: Json<serde_json::Value> = sqlx::query_scalar(
197-
"SELECT payload FROM queue_jobs WHERE queue_name = 'reactivate-user'",
198-
)
199-
.fetch_one(&pool)
200-
.await
201-
.expect("Reactivation job to be scheduled");
202-
assert_eq!(job["user_id"], serde_json::json!(user.id));
203-
assert_eq!(job["unlock"], serde_json::Value::Bool(false));
196+
assert_eq!(
197+
body["data"]["attributes"]["deactivated_at"],
198+
serde_json::Value::Null
199+
);
204200
}
205201

206202
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

0 commit comments

Comments
 (0)