diff --git a/src/auth.rs b/src/auth.rs index 5d5d314da94..e9e94571320 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -18,6 +18,7 @@ pub struct AuthCheck { allow_token: bool, endpoint_scope: Option, crate_name: Option, + only_admin: bool, } impl AuthCheck { @@ -29,6 +30,7 @@ impl AuthCheck { allow_token: true, endpoint_scope: None, crate_name: None, + only_admin: false, } } @@ -38,6 +40,7 @@ impl AuthCheck { allow_token: false, endpoint_scope: None, crate_name: None, + only_admin: false, } } @@ -46,6 +49,7 @@ impl AuthCheck { allow_token: self.allow_token, endpoint_scope: Some(endpoint_scope), crate_name: self.crate_name.clone(), + only_admin: false, } } @@ -54,9 +58,15 @@ impl AuthCheck { allow_token: self.allow_token, endpoint_scope: self.endpoint_scope, crate_name: Some(crate_name.to_string()), + only_admin: false, } } + pub fn require_admin(mut self) -> Self { + self.only_admin = true; + self + } + #[instrument(name = "auth.check", skip_all)] pub async fn check( &self, @@ -65,6 +75,15 @@ impl AuthCheck { ) -> AppResult { let auth = authenticate(parts, conn).await?; + if self.only_admin && !auth.user().is_admin { + let error_message = "User must be an admin to access this API"; + parts.request_log().add("cause", error_message); + + return Err(forbidden( + "this action can only be performed by a site admin", + )); + } + if let Some(token) = auth.api_token() { if !self.allow_token { let error_message = diff --git a/src/controllers/user.rs b/src/controllers/user.rs index 4a604d16077..2b81b8b2d30 100644 --- a/src/controllers/user.rs +++ b/src/controllers/user.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod email_notifications; pub mod email_verification; pub mod me; diff --git a/src/controllers/user/admin.rs b/src/controllers/user/admin.rs new file mode 100644 index 00000000000..17984e6a151 --- /dev/null +++ b/src/controllers/user/admin.rs @@ -0,0 +1,185 @@ +use axum::{extract::Path, Json}; +use chrono::{NaiveDateTime, Utc}; +use crates_io_database::schema::{emails, users}; +use diesel::{pg::Pg, prelude::*}; +use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl}; +use http::request::Parts; +use utoipa::ToSchema; + +use crate::{ + app::AppState, auth::AuthCheck, models::User, sql::lower, util::errors::AppResult, + util::rfc3339, views::EncodableAdminUser, +}; + +/// Find user by login, returning the admin's view of the user. +/// +/// Only site admins can use this endpoint. +#[utoipa::path( + get, + path = "/api/v1/users/{user}/admin", + params( + ("user" = String, Path, description = "Login name of the user"), + ), + tags = ["admin", "users"], + responses((status = 200, description = "Successful Response")), +)] +pub async fn get( + state: AppState, + Path(user_name): Path, + req: Parts, +) -> AppResult> { + let mut conn = state.db_read_prefer_primary().await?; + AuthCheck::only_cookie() + .require_admin() + .check(&req, &mut conn) + .await?; + + get_user( + |query| query.filter(lower(users::gh_login).eq(lower(user_name))), + &mut conn, + ) + .await + .map(Json) +} + +#[derive(Deserialize, ToSchema)] +pub struct LockRequest { + /// The reason for locking the account. This is visible to the user. + reason: String, + + /// When to lock the account until. If omitted, the lock will be indefinite. + #[serde(default, with = "rfc3339::option")] + until: Option, +} + +/// Lock the given user. +/// +/// Only site admins can use this endpoint. +#[utoipa::path( + put, + path = "/api/v1/users/{user}/lock", + params( + ("user" = String, Path, description = "Login name of the user"), + ), + request_body = LockRequest, + tags = ["admin", "users"], + responses((status = 200, description = "Successful Response")), +)] +pub async fn lock( + state: AppState, + Path(user_name): Path, + req: Parts, + Json(LockRequest { reason, until }): Json, +) -> AppResult> { + let mut conn = state.db_read_prefer_primary().await?; + AuthCheck::only_cookie() + .require_admin() + .check(&req, &mut conn) + .await?; + + // In theory, we could cook up a complicated update query that returns + // everything we need to build an `EncodableAdminUser`, but that feels hard. + // Instead, let's use a small transaction to get the same effect. + let user = conn + .transaction(|conn| { + async move { + let id = diesel::update(users::table) + .filter(lower(users::gh_login).eq(lower(user_name))) + .set(( + users::account_lock_reason.eq(reason), + users::account_lock_until.eq(until), + )) + .returning(users::id) + .get_result::(conn) + .await?; + + get_user(|query| query.filter(users::id.eq(id)), conn).await + } + .scope_boxed() + }) + .await?; + + 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, + req: Parts, +) -> AppResult> { + 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::(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. +/// +/// It would be ill advised to do anything in `filter` other than calling +/// [`QueryDsl::filter`] on the given query, but I'm not the boss of you. +async fn get_user(filter: F, conn: &mut Conn) -> AppResult +where + Conn: AsyncConnection, + F: FnOnce(users::BoxedQuery<'_, Pg>) -> users::BoxedQuery<'_, Pg>, +{ + let query = filter(users::table.into_boxed()); + + let (user, verified, email, verification_sent): (User, Option, Option, bool) = + query + .left_join(emails::table) + .select(( + User::as_select(), + emails::verified.nullable(), + emails::email.nullable(), + emails::token_generated_at.nullable().is_not_null(), + )) + .first(conn) + .await?; + + let verified = verified.unwrap_or(false); + let verification_sent = verified || verification_sent; + Ok(EncodableAdminUser::from( + user, + email, + verified, + verification_sent, + )) +} diff --git a/src/router.rs b/src/router.rs index 50776d671de..06cc8a57eab 100644 --- a/src/router.rs +++ b/src/router.rs @@ -61,6 +61,8 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(team::find_team)) .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, 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)) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 507fab5e6dc..58160a013f5 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -1,10 +1,32 @@ --- source: src/openapi.rs expression: response.json() -snapshot_kind: text --- { - "components": {}, + "components": { + "schemas": { + "LockRequest": { + "properties": { + "reason": { + "description": "The reason for locking the account. This is visible to the user.", + "type": "string" + }, + "until": { + "description": "When to lock the account until. If omitted, the lock will be indefinite.", + "format": "date-time", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "reason" + ], + "type": "object" + } + } + }, "info": { "contact": { "email": "help@crates.io", @@ -1650,6 +1672,95 @@ snapshot_kind: text "users" ] } + }, + "/api/v1/users/{user}/admin": { + "get": { + "description": "Only site admins can use this endpoint.", + "operationId": "get", + "parameters": [ + { + "description": "Login name of the user", + "in": "path", + "name": "user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + } + }, + "summary": "Find user by login, returning the admin's view of the user.", + "tags": [ + "admin", + "users" + ] + } + }, + "/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", + "parameters": [ + { + "description": "Login name of the user", + "in": "path", + "name": "user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LockRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response" + } + }, + "summary": "Lock the given user.", + "tags": [ + "admin", + "users" + ] + } } }, "servers": [ diff --git a/src/tests/routes/users/admin.rs b/src/tests/routes/users/admin.rs new file mode 100644 index 00000000000..ca7ce338704 --- /dev/null +++ b/src/tests/routes/users/admin.rs @@ -0,0 +1,253 @@ +use chrono::{DateTime, Utc}; +use crates_io_database::schema::users; +use http::StatusCode; +use insta::{assert_json_snapshot, assert_snapshot}; +use serde_json::json; + +use crate::{ + models::User, + tests::util::{RequestHelper, TestApp}, +}; + +mod get { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn get() { + let (app, anon, user) = TestApp::init().with_user().await; + let admin = app.db_new_admin_user("admin").await; + + // Anonymous users should be forbidden. + let response = anon.get::<()>("/api/v1/users/foo/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!("anonymous-found", response.text()); + + let response = anon.get::<()>("/api/v1/users/bar/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!("anonymous-not-found", response.text()); + + // Regular users should also be forbidden, even if they're requesting + // themself. + let response = user.get::<()>("/api/v1/users/foo/admin").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!("non-admin-found", response.text()); + + let response = user.get::<()>("/api/v1/users/bar/admin").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.get::<()>("/api/v1/users/bar/admin").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_snapshot!("admin-not-found", response.text()); + + // Admin users are allowed, and should get an admin's eye view of the + // requested user. + let response = admin.get::<()>("/api/v1/users/foo/admin").await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!("admin-found", response.json()); + } +} + +mod lock { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn lock() { + let (app, anon, user) = TestApp::init().with_user().await; + let admin = app.db_new_admin_user("admin").await; + + // Because axum will validate and deserialise the body before any auth + // check occurs, we actually need to provide a valid body for all the + // auth related test cases. + let body = serde_json::to_string(&json!({ + "reason": "l33t h4x0r", + "until": "2045-01-01T01:02:03Z", + })) + .unwrap(); + + // Anonymous users should be forbidden. + let response = anon.put::<()>("/api/v1/users/foo/lock", body.clone()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!("anonymous-found", response.text()); + + let response = anon.put::<()>("/api/v1/users/bar/lock", body.clone()).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.put::<()>("/api/v1/users/foo/lock", body.clone()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!("non-admin-found", response.text()); + + let response = user.put::<()>("/api/v1/users/bar/lock", body.clone()).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 + .put::<()>("/api/v1/users/bar/lock", body.clone()) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_snapshot!("admin-not-found", response.text()); + + // Admin users who provide invalid request bodies should be denied. + let response = admin + .put::<()>("/api/v1/users/bar/lock", b"invalid JSON" as &[u8]) + .await; + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_snapshot!("admin-invalid-json", response.text()); + + let response = admin + .put::<()>("/api/v1/users/bar/lock", br#"{"valid": "json"}"# as &[u8]) + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_snapshot!("admin-malformed-json", response.text()); + + // Admin users are allowed, and should be able to lock the user. + assert_none!(&user.as_model().account_lock_reason); + assert_none!(&user.as_model().account_lock_until); + + let response = admin.put::<()>("/api/v1/users/foo/lock", body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!("admin-found", response.json()); + + // Get the user again and validate that they are now locked. + let mut conn = app.db_conn().await; + let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap(); + assert_user_is_locked(&locked_user, "l33t h4x0r", "2045-01-01T01:02:03Z"); + + // Re-locking a locked user should update their lock reason and + // expiration time. + let body = serde_json::to_string(&json!({ + "reason": "less l33t", + "until": "2035-01-01T01:02:03Z", + })) + .unwrap(); + + let response = admin.put::<()>("/api/v1/users/foo/lock", body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!("admin-relock-shorter", response.json()); + + // Get the user again and validate that they are now locked for less + // time. + let mut conn = app.db_conn().await; + let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap(); + assert_user_is_locked(&locked_user, "less l33t", "2035-01-01T01:02:03Z"); + + // Finally, not including an until time at all should lock the account + // forever. (Insert evil laughter here.) + let body = serde_json::to_string(&json!({ + "reason": "less l33t", + })) + .unwrap(); + + let response = admin.put::<()>("/api/v1/users/foo/lock", body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!("admin-lock-forever", response.json()); + + // Get the user again and validate that they are now locked forever. + let mut conn = app.db_conn().await; + let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap(); + assert_user_is_locked_indefinitely(&locked_user, "less l33t"); + } +} + +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)); + assert_eq!( + user.account_lock_until, + Some(DateTime::parse_from_rfc3339(until).unwrap().naive_utc()) + ); +} + +#[track_caller] +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"); + } + } +} diff --git a/src/tests/routes/users/mod.rs b/src/tests/routes/users/mod.rs index c788314a57b..591f9420563 100644 --- a/src/tests/routes/users/mod.rs +++ b/src/tests/routes/users/mod.rs @@ -1,3 +1,4 @@ +mod admin; mod read; mod stats; pub mod update; diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-found.snap new file mode 100644 index 00000000000..13af2075aa2 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-found.snap @@ -0,0 +1,17 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "email_verification_sent": true, + "email_verified": true, + "id": 1, + "is_admin": false, + "lock": null, + "login": "foo", + "name": null, + "publish_notifications": true, + "url": "https://github.com/foo" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-not-found.snap new file mode 100644 index 00000000000..83a124455ed --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__admin-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"Not Found"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-not-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__anonymous-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-found.snap new file mode 100644 index 00000000000..d6906f7318f --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action can only be performed by a site admin"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-not-found.snap new file mode 100644 index 00000000000..d6906f7318f --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__get__non-admin-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action can only be performed by a site admin"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-found.snap new file mode 100644 index 00000000000..331b183069a --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-found.snap @@ -0,0 +1,20 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "email_verification_sent": true, + "email_verified": true, + "id": 1, + "is_admin": false, + "lock": { + "reason": "l33t h4x0r", + "until": "2045-01-01T01:02:03+00:00" + }, + "login": "foo", + "name": null, + "publish_notifications": true, + "url": "https://github.com/foo" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-invalid-json.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-invalid-json.snap new file mode 100644 index 00000000000..95df17a2fda --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-invalid-json.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"Expected request with `Content-Type: application/json`"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-lock-forever.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-lock-forever.snap new file mode 100644 index 00000000000..dccf02d759c --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-lock-forever.snap @@ -0,0 +1,20 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "email_verification_sent": true, + "email_verified": true, + "id": 1, + "is_admin": false, + "lock": { + "reason": "less l33t", + "until": null + }, + "login": "foo", + "name": null, + "publish_notifications": true, + "url": "https://github.com/foo" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-malformed-json.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-malformed-json.snap new file mode 100644 index 00000000000..9a9f0f3ff2b --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-malformed-json.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"Failed to deserialize the JSON body into the target type: missing field `reason` at line 1 column 17"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-not-found.snap new file mode 100644 index 00000000000..83a124455ed --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"Not Found"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-relock-shorter.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-relock-shorter.snap new file mode 100644 index 00000000000..479e09f3237 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__admin-relock-shorter.snap @@ -0,0 +1,20 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "email_verification_sent": true, + "email_verified": true, + "id": 1, + "is_admin": false, + "lock": { + "reason": "less l33t", + "until": "2035-01-01T01:02:03+00:00" + }, + "login": "foo", + "name": null, + "publish_notifications": true, + "url": "https://github.com/foo" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-not-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__anonymous-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-found.snap new file mode 100644 index 00000000000..d6906f7318f --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action can only be performed by a site admin"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-not-found.snap new file mode 100644 index 00000000000..d6906f7318f --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__lock__non-admin-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action can only be performed by a site admin"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-found.snap new file mode 100644 index 00000000000..00079b77bce --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-found.snap @@ -0,0 +1,20 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "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" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-not-found.snap new file mode 100644 index 00000000000..83a124455ed --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"Not Found"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-reunlock.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-reunlock.snap new file mode 100644 index 00000000000..00079b77bce --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__admin-reunlock.snap @@ -0,0 +1,20 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.json() +--- +{ + "avatar": null, + "email": "foo@example.com", + "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" +} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-not-found.snap new file mode 100644 index 00000000000..4282cf055f9 --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__anonymous-not-found.snap @@ -0,0 +1,5 @@ +--- +source: src/tests/routes/users/admin.rs +expression: response.text() +--- +{"errors":[{"detail":"this action requires authentication"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-found.snap new file mode 100644 index 00000000000..d016dc2b92d --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-found.snap @@ -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"}]} diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-not-found.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-not-found.snap new file mode 100644 index 00000000000..d016dc2b92d --- /dev/null +++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__admin__unlock__non-admin-not-found.snap @@ -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"}]} diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index feeaf6e7006..9d272f93698 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -150,6 +150,23 @@ impl TestApp { } } + pub async fn db_new_admin_user(&self, username: &str) -> MockCookieUser { + use diesel::prelude::*; + use diesel_async::RunQueryDsl; + + let MockCookieUser { app, user } = self.db_new_user(username).await; + + let mut conn = self.db_conn().await; + let user = diesel::update(users::table) + .filter(users::id.eq(user.id)) + .set(users::is_admin.eq(true)) + .get_result::(&mut conn) + .await + .unwrap(); + + MockCookieUser { app, user } + } + /// Obtain a reference to the upstream repository ("the index") pub fn upstream_index(&self) -> &UpstreamIndex { assert_some!(self.0.index.as_ref()) diff --git a/src/views.rs b/src/views.rs index 239699a9dce..c0c71f111bf 100644 --- a/src/views.rs +++ b/src/views.rs @@ -467,6 +467,39 @@ pub struct EncodableMe { pub owned_crates: Vec, } +/// The admin's view of the serialisable user. +/// +/// Essentially a superset of [`EncodablePrivateUser`] with the user's current +/// lock status. +#[derive(Deserialize, Serialize, Debug)] +pub struct EncodableAdminUser { + #[serde(flatten)] + pub user: EncodablePrivateUser, + pub lock: Option, +} + +impl EncodableAdminUser { + pub fn from( + mut user: User, + email: Option, + email_verified: bool, + email_verification_sent: bool, + ) -> Self { + let lock = user + .account_lock_reason + .take() + .map(|reason| EncodableUserLock { + reason, + until: user.account_lock_until, + }); + + Self { + user: EncodablePrivateUser::from(user, email, email_verified, email_verification_sent), + lock, + } + } +} + /// The serialization format for the `User` model. /// Same as public user, except for addition of /// email field @@ -550,6 +583,13 @@ impl From for EncodablePublicUser { } } +#[derive(Deserialize, Serialize, Debug)] +pub struct EncodableUserLock { + reason: String, + #[serde(with = "rfc3339::option")] + until: Option, +} + #[derive(Deserialize, Serialize, Debug)] pub struct EncodableAuditAction { pub action: String,