diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index b1154ce92..0257adf3b 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -127,6 +127,13 @@ where self::user_registration_tokens::list_doc, ), ) + .api_route( + "/user-registration-tokens/{id}", + get_with( + self::user_registration_tokens::get, + self::user_registration_tokens::get_doc, + ), + ) .api_route( "/upstream-oauth-links", get_with( diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs new file mode 100644 index 000000000..a9714194d --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs @@ -0,0 +1,171 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserRegistrationToken, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getUserRegistrationToken") + .summary("Get a user registration token") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UserRegistrationToken::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Registration token was found") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.get", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let token = repo + .user_registration_token() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical( + UserRegistrationToken::from(token), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_123".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_token_123", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_nonexistent_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Use a fixed ID for the test to ensure consistent snapshots + let nonexistent_id = Ulid::from_string("00000000000000000000000000").unwrap(); + let request = Request::get(format!( + "/api/admin/v1/user-registration-tokens/{nonexistent_id}" + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs index e1bd34314..126ff168a 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -3,6 +3,10 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod get; mod list; -pub use self::list::{doc as list_doc, handler as list}; +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; diff --git a/docs/api/spec.json b/docs/api/spec.json index 138264bb4..ea1f9696c 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2292,6 +2292,77 @@ } } }, + "/api/admin/v1/user-registration-tokens/{id}": { + "get": { + "tags": [ + "user-registration-token" + ], + "summary": "Get a user registration token", + "operationId": "getUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Registration token was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/upstream-oauth-links": { "get": { "tags": [ @@ -3878,6 +3949,22 @@ } } }, + "SingleResponse_for_UserRegistrationToken": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } + }, "UpstreamOAuthLinkFilter": { "type": "object", "properties": {