Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 102 additions & 11 deletions crates/handlers/src/admin/v1/users/deactivate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use mas_storage::{
BoxRng,
queue::{DeactivateUserJob, QueueJobRepositoryExt as _},
};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing::info;
use ulid::Ulid;

Expand Down Expand Up @@ -49,7 +51,30 @@ impl IntoResponse for RouteError {
}
}

pub fn doc(operation: TransformOperation) -> TransformOperation {
/// # JSON payload for the `POST /api/admin/v1/users/:id/deactivate` endpoint
#[derive(Deserialize, JsonSchema)]
#[serde(rename = "DeactivateUserRequest")]
pub struct Request {
/// Whether the user should be GDPR-erased from the homeserver.
erase: bool,
}

impl Default for Request {
fn default() -> Self {
Self { erase: true }
}
}

pub fn doc(mut operation: TransformOperation) -> TransformOperation {
operation
.inner_mut()
.request_body
.as_mut()
.unwrap()
.as_item_mut()
.unwrap()
.required = false;

operation
.id("deactivateUser")
.summary("Deactivate a user")
Expand All @@ -76,7 +101,9 @@ pub async fn handler(
}: CallContext,
NoApi(mut rng): NoApi<BoxRng>,
id: UlidPathParam,
body: Option<Json<Request>>,
) -> Result<Json<SingleResponse<User>>, RouteError> {
let Json(params) = body.unwrap_or_default();
let id = *id;
let mut user = repo
.user()
Expand All @@ -90,7 +117,11 @@ pub async fn handler(

info!(%user.id, "Scheduling deactivation of user");
repo.queue_job()
.schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true))
.schedule_job(
&mut rng,
&clock,
DeactivateUserJob::new(&user, params.erase),
)
.await?;

repo.save().await?;
Expand All @@ -105,14 +136,13 @@ pub async fn handler(
mod tests {
use chrono::Duration;
use hyper::{Request, StatusCode};
use insta::assert_json_snapshot;
use insta::{allow_duplicates, assert_json_snapshot};
use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
use sqlx::PgPool;
use sqlx::{PgPool, types::Json};

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

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_deactivate_user(pool: PgPool) {
async fn test_deactivate_user_helper(pool: PgPool, erase: Option<bool>) {
setup();
let mut state = TestState::from_pool(pool.clone()).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;
Expand All @@ -125,9 +155,14 @@ mod tests {
.unwrap();
repo.save().await.unwrap();

let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
.bearer(&token)
.empty();
let request =
Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)).bearer(&token);
let request = match erase {
None => request.empty(),
Some(erase) => request.json(serde_json::json!({
"erase": erase,
})),
};
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
Expand All @@ -138,6 +173,17 @@ mod tests {
serde_json::json!(state.clock.now())
);

// It should have scheduled a deactivation job for the user
// XXX: we don't have a good way to look for the deactivation job
let job: Json<serde_json::Value> = sqlx::query_scalar(
"SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
)
.fetch_one(&pool)
.await
.expect("Deactivation job to be scheduled");
assert_eq!(job["user_id"], serde_json::json!(user.id));
assert_eq!(job["hs_erase"], serde_json::json!(erase.unwrap_or(true)));

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept this in to check for the erase parameter on the job, as the response to GET /users doesn't indicate whether the user was erased or not.

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

Expand All @@ -148,7 +194,7 @@ mod tests {
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();

assert_json_snapshot!(body, @r#"
allow_duplicates!(assert_json_snapshot!(body, @r#"
{
"data": {
"type": "user",
Expand All @@ -168,7 +214,52 @@ mod tests {
"self": "/api/admin/v1/users/01FSHN9AG0MZAA6S4AF7CTV32E"
}
}
"#);
"#));
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_deactivate_user(pool: PgPool) {
test_deactivate_user_helper(pool, Option::None).await;
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_deactivate_user_with_explicit_erase(pool: PgPool) {
test_deactivate_user_helper(pool, Option::Some(true)).await;
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_deactivate_user_without_erase(pool: PgPool) {
test_deactivate_user_helper(pool, Option::Some(false)).await;
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_deactivate_user_missing_erase(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool.clone()).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.add(&mut state.rng(), &state.clock, "alice".to_owned())
.await
.unwrap();
repo.save().await.unwrap();

let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
.bearer(&token)
.json(serde_json::json!({}));
let response = state.request(request).await;
response.assert_status(StatusCode::UNPROCESSABLE_ENTITY);

// It should have not scheduled a deactivation job for the user
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(1) FROM queue_jobs WHERE queue_name = 'deactivate-user'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0);
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
Expand Down
22 changes: 22 additions & 0 deletions docs/api/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,15 @@
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeactivateUserRequest"
}
}
}
},
"responses": {
"200": {
"description": "User was deactivated",
Expand Down Expand Up @@ -3872,6 +3881,19 @@
}
}
},
"DeactivateUserRequest": {
"title": "JSON payload for the `POST /api/admin/v1/users/:id/deactivate` endpoint",
"type": "object",
"required": [
"erase"
],
"properties": {
"erase": {
"description": "Whether the user should be GDPR-erased from the homeserver.",
"type": "boolean"
}
}
},
"UserEmailFilter": {
"type": "object",
"properties": {
Expand Down
Loading