diff --git a/crates/handlers/src/admin/v1/compat_sessions/finish.rs b/crates/handlers/src/admin/v1/compat_sessions/finish.rs new file mode 100644 index 000000000..c0299d960 --- /dev/null +++ b/crates/handlers/src/admin/v1/compat_sessions/finish.rs @@ -0,0 +1,246 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{CompatSession, Resource}, + 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("Compatibility session with ID {0} not found")] + NotFound(Ulid), + + #[error("Compatibility session with ID {0} is already finished")] + AlreadyFinished(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, + Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("finishCompatSession") + .summary("Finish a compatibility session") + .description( + "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + ) + .tag("compat-session") + .response_with::<200, Json>, _>(|t| { + // Get the finished session sample + let [_, finished_session, _] = CompatSession::samples(); + let id = finished_session.id(); + let response = SingleResponse::new( + finished_session, + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ); + t.description("Compatibility session was finished").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil())); + t.description("Session is already finished") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Compatibility session was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.compat_sessions.finish", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let session = repo + .compat_session() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the session is already finished + if session.finished_at().is_some() { + return Err(RouteError::AlreadyFinished(id)); + } + + // Load the user to schedule a device sync job + let user = repo + .user() + .lookup(session.user_id) + .await? + .ok_or_else(|| RouteError::Internal("User not found for session".into()))?; + + // Schedule a job to sync the devices of the user with the homeserver + tracing::info!(user.id = %user.id, "Scheduling device sync job for user"); + repo.queue_job() + .schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user)) + .await?; + + // Finish the session + let session = repo.compat_session().finish(&clock, session).await?; + + // Get the SSO login info for the response + let sso_login = repo.compat_sso_login().find_for_session(&session).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + CompatSession::from((session, sso_login)), + format!("/api/admin/v1/compat-sessions/{id}/finish"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock as _, Device}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The finished_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["finished_at"], + serde_json::json!(state.clock.now()) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_already_finished_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and a compat session + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + + // Finish the session first + let session = repo + .compat_session() + .finish(&state.clock, session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Move the clock forward + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!( + "/api/admin/v1/compat-sessions/{}/finish", + session.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + format!( + "Compatibility session with ID {} is already finished", + session.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_unknown_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = + Request::post("/api/admin/v1/compat-sessions/01040G2081040G2081040G2081/finish") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Compatibility session with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/compat_sessions/mod.rs b/crates/handlers/src/admin/v1/compat_sessions/mod.rs index 18ffe5af6..db7b17ff5 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/mod.rs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +mod finish; mod get; mod list; pub use self::{ + finish::{doc as finish_doc, handler as finish}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index c6cb466ac..7146328be 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -52,6 +52,13 @@ where "/compat-sessions/{id}", get_with(self::compat_sessions::get, self::compat_sessions::get_doc), ) + .api_route( + "/compat-sessions/{id}/finish", + post_with( + self::compat_sessions::finish, + self::compat_sessions::finish_doc, + ), + ) .api_route( "/oauth2-sessions", get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), diff --git a/docs/api/spec.json b/docs/api/spec.json index 02a07a408..8e003268a 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -342,6 +342,98 @@ } } }, + "/api/admin/v1/compat-sessions/{id}/finish": { + "post": { + "tags": [ + "compat-session" + ], + "summary": "Finish a compatibility session", + "description": "Calling this endpoint will finish the compatibility session, preventing any further use. A job will be scheduled to sync the user's devices with the homeserver.", + "operationId": "finishCompatSession", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Compatibility session was finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_CompatSession" + }, + "example": { + "data": { + "type": "compat-session", + "id": "02081040G2081040G2081040G2", + "attributes": { + "user_id": "01040G2081040G2081040G2081", + "device_id": "FFGGHHIIJJ", + "user_session_id": "0J289144GJ289144GJ289144GJ", + "redirect_uri": null, + "created_at": "1970-01-01T00:00:00Z", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "1.2.3.4", + "finished_at": "1970-01-01T00:00:00Z", + "human_name": null + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2/finish" + } + } + } + } + }, + "400": { + "description": "Session is already finished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 is already finished" + } + ] + } + } + } + }, + "404": { + "description": "Compatibility session was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Compatibility session with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/oauth2-sessions": { "get": { "tags": [