Skip to content

Commit

Permalink
controllers/user/admin: add an unlock route
Browse files Browse the repository at this point in the history
  • Loading branch information
LawnGnome committed Dec 19, 2024
1 parent bc5d6a4 commit 2f06ce1
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 3 deletions.
50 changes: 49 additions & 1 deletion src/controllers/user/admin.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use axum::{extract::Path, Json};
use chrono::NaiveDateTime;
use chrono::{NaiveDateTime, Utc};
use crates_io_database::schema::{emails, users};
use diesel::{pg::Pg, prelude::*};
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl};
Expand Down Expand Up @@ -102,6 +102,54 @@ pub async fn lock(
Ok(Json(user))
}

/// Unlock the given user.
///
/// Only site admins can use this endpoint.
#[utoipa::path(
delete,
path = "/api/v1/users/{user}/lock",
params(
("user" = String, Path, description = "Login name of the user"),
),
tags = ["admin", "users"],
responses((status = 200, description = "Successful Response")),
)]
pub async fn unlock(
state: AppState,
Path(user_name): Path<String>,
req: Parts,
) -> AppResult<Json<EncodableAdminUser>> {
let mut conn = state.db_read_prefer_primary().await?;
AuthCheck::only_cookie()
.require_admin()
.check(&req, &mut conn)
.await?;

// Again, let's do this in a transaction, even though we _technically_ don't
// need to.
let user = conn
.transaction(|conn| {
// Although this is called via the `DELETE` method, this is
// implemented as a soft deletion by setting the lock until time to
// now, thereby allowing us to have some sense of history of whether
// an account has been locked in the past.
async move {
let id = diesel::update(users::table)
.filter(lower(users::gh_login).eq(lower(user_name)))
.set(users::account_lock_until.eq(Utc::now().naive_utc()))
.returning(users::id)
.get_result::<i32>(conn)
.await?;

get_user(|query| query.filter(users::id.eq(id)), conn).await
}
.scope_boxed()
})
.await?;

Ok(Json(user))
}

/// A helper to get an [`EncodableAdminUser`] based on whatever filter predicate
/// is provided in the callback.
///
Expand Down
2 changes: 1 addition & 1 deletion src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
.routes(routes!(user::me::get_authenticated_user))
.routes(routes!(user::me::get_authenticated_user_updates))
.routes(routes!(user::admin::get))
.routes(routes!(user::admin::lock))
.routes(routes!(user::admin::lock, user::admin::unlock))
.routes(routes!(token::list_api_tokens, token::create_api_token))
.routes(routes!(token::find_api_token, token::revoke_api_token))
.routes(routes!(token::revoke_current_api_token))
Expand Down
25 changes: 25 additions & 0 deletions src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,31 @@ expression: response.json()
}
},
"/api/v1/users/{user}/lock": {
"delete": {
"description": "Only site admins can use this endpoint.",
"operationId": "unlock",
"parameters": [
{
"description": "Login name of the user",
"in": "path",
"name": "user",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful Response"
}
},
"summary": "Unlock the given user.",
"tags": [
"admin",
"users"
]
},
"put": {
"description": "Only site admins can use this endpoint.",
"operationId": "lock",
Expand Down
84 changes: 83 additions & 1 deletion src/tests/routes/users/admin.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use chrono::DateTime;
use chrono::{DateTime, Utc};
use crates_io_database::schema::users;
use http::StatusCode;
use insta::{assert_json_snapshot, assert_snapshot};
use serde_json::json;
Expand Down Expand Up @@ -155,6 +156,76 @@ mod lock {
}
}

mod unlock {
use super::*;

#[tokio::test(flavor = "multi_thread")]
async fn unlock() {
let (app, anon, user) = TestApp::init().with_user().await;
let admin = app.db_new_admin_user("admin").await;

use diesel::prelude::*;
use diesel_async::RunQueryDsl;

// First up, let's lock the user.
let mut conn = app.db_conn().await;
diesel::update(user.as_model())
.set((
users::account_lock_reason.eq("naughty naughty"),
users::account_lock_until.eq(DateTime::parse_from_rfc3339("2050-01-01T01:02:03Z")
.unwrap()
.naive_utc()),
))
.execute(&mut conn)
.await
.unwrap();

// Anonymous users should be forbidden.
let response = anon.delete::<()>("/api/v1/users/foo/lock").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_snapshot!("anonymous-found", response.text());

let response = anon.delete::<()>("/api/v1/users/bar/lock").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_snapshot!("anonymous-not-found", response.text());

// Regular users should also be forbidden, even if they're locking
// themself.
let response = user.delete::<()>("/api/v1/users/foo/lock").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_snapshot!("non-admin-found", response.text());

let response = user.delete::<()>("/api/v1/users/bar/lock").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_snapshot!("non-admin-not-found", response.text());

// Admin users are allowed, but still can't manifest users who don't
// exist.
let response = admin.delete::<()>("/api/v1/users/bar/lock").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_snapshot!("admin-not-found", response.text());

// Admin users are allowed, and should be able to unlock the user.
let response = admin.delete::<()>("/api/v1/users/foo/lock").await;
assert_eq!(response.status(), StatusCode::OK);
assert_json_snapshot!("admin-found", response.json(), {
".lock.until" => "[datetime]",
});

// Get the user again and validate that they are now unlocked.
let mut conn = app.db_conn().await;
let unlocked_user = User::find(&mut conn, user.as_model().id).await.unwrap();
assert_user_is_unlocked(&unlocked_user);

// Unlocking an unlocked user should succeed silently.
let response = admin.delete::<()>("/api/v1/users/foo/lock").await;
assert_eq!(response.status(), StatusCode::OK);
assert_json_snapshot!("admin-reunlock", response.json(), {
".lock.until" => "[datetime]",
});
}
}

#[track_caller]
fn assert_user_is_locked(user: &User, reason: &str, until: &str) {
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
Expand All @@ -169,3 +240,14 @@ fn assert_user_is_locked_indefinitely(user: &User, reason: &str) {
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
assert_none!(user.account_lock_until);
}

#[track_caller]
fn assert_user_is_unlocked(user: &User) {
if user.account_lock_reason.is_some() {
if let Some(until) = user.account_lock_until {
assert_lt!(until, Utc::now().naive_utc());
} else {
panic!("user account is locked indefinitely");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
source: src/tests/routes/users/admin.rs
expression: response.json()
---
{
"avatar": null,
"email": "[email protected]",
"email_verification_sent": true,
"email_verified": true,
"id": 1,
"is_admin": false,
"lock": {
"reason": "naughty naughty",
"until": "[datetime]"
},
"login": "foo",
"name": null,
"publish_notifications": true,
"url": "https://github.com/foo"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/routes/users/admin.rs
expression: response.text()
---
{"errors":[{"detail":"Not Found"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
source: src/tests/routes/users/admin.rs
expression: response.json()
---
{
"avatar": null,
"email": "[email protected]",
"email_verification_sent": true,
"email_verified": true,
"id": 1,
"is_admin": false,
"lock": {
"reason": "naughty naughty",
"until": "[datetime]"
},
"login": "foo",
"name": null,
"publish_notifications": true,
"url": "https://github.com/foo"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/routes/users/admin.rs
expression: response.text()
---
{"errors":[{"detail":"this action requires authentication"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/routes/users/admin.rs
expression: response.text()
---
{"errors":[{"detail":"this action requires authentication"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/routes/users/admin.rs
expression: response.text()
---
{"errors":[{"detail":"This account is locked until 2050-01-01 at 01:02:03 UTC. Reason: naughty naughty"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: src/tests/routes/users/admin.rs
expression: response.text()
---
{"errors":[{"detail":"This account is locked until 2050-01-01 at 01:02:03 UTC. Reason: naughty naughty"}]}

0 comments on commit 2f06ce1

Please sign in to comment.