Skip to content

Commit b856c88

Browse files
committed
Admin API to finish a compatibility session
1 parent c512b72 commit b856c88

File tree

4 files changed

+347
-0
lines changed

4 files changed

+347
-0
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
use aide::{NoApi, OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use mas_axum_utils::record_error;
10+
use mas_data_model::BoxRng;
11+
use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob};
12+
use ulid::Ulid;
13+
14+
use crate::{
15+
admin::{
16+
call_context::CallContext,
17+
model::{CompatSession, Resource},
18+
params::UlidPathParam,
19+
response::{ErrorResponse, SingleResponse},
20+
},
21+
impl_from_error_for_route,
22+
};
23+
24+
#[derive(Debug, thiserror::Error, OperationIo)]
25+
#[aide(output_with = "Json<ErrorResponse>")]
26+
pub enum RouteError {
27+
#[error(transparent)]
28+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
29+
30+
#[error("Compatibility session with ID {0} not found")]
31+
NotFound(Ulid),
32+
33+
#[error("Compatibility session with ID {0} is already finished")]
34+
AlreadyFinished(Ulid),
35+
}
36+
37+
impl_from_error_for_route!(mas_storage::RepositoryError);
38+
39+
impl IntoResponse for RouteError {
40+
fn into_response(self) -> axum::response::Response {
41+
let error = ErrorResponse::from_error(&self);
42+
let sentry_event_id = record_error!(self, Self::Internal(_));
43+
let status = match self {
44+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
45+
Self::NotFound(_) => StatusCode::NOT_FOUND,
46+
Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST,
47+
};
48+
(status, sentry_event_id, Json(error)).into_response()
49+
}
50+
}
51+
52+
pub fn doc(operation: TransformOperation) -> TransformOperation {
53+
operation
54+
.id("finishCompatSession")
55+
.summary("Finish a compatibility session")
56+
.description(
57+
"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.",
58+
)
59+
.tag("compat-session")
60+
.response_with::<200, Json<SingleResponse<CompatSession>>, _>(|t| {
61+
// Get the finished session sample
62+
let [_, finished_session, _] = CompatSession::samples();
63+
let id = finished_session.id();
64+
let response = SingleResponse::new(
65+
finished_session,
66+
format!("/api/admin/v1/compat-sessions/{id}/finish"),
67+
);
68+
t.description("Compatibility session was finished").example(response)
69+
})
70+
.response_with::<400, RouteError, _>(|t| {
71+
let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil()));
72+
t.description("Session is already finished")
73+
.example(response)
74+
})
75+
.response_with::<404, RouteError, _>(|t| {
76+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
77+
t.description("Compatibility session was not found")
78+
.example(response)
79+
})
80+
}
81+
82+
#[tracing::instrument(name = "handler.admin.v1.compat_sessions.finish", skip_all)]
83+
pub async fn handler(
84+
CallContext {
85+
mut repo, clock, ..
86+
}: CallContext,
87+
NoApi(mut rng): NoApi<BoxRng>,
88+
id: UlidPathParam,
89+
) -> Result<Json<SingleResponse<CompatSession>>, RouteError> {
90+
let id = *id;
91+
let session = repo
92+
.compat_session()
93+
.lookup(id)
94+
.await?
95+
.ok_or(RouteError::NotFound(id))?;
96+
97+
// Check if the session is already finished
98+
if session.finished_at().is_some() {
99+
return Err(RouteError::AlreadyFinished(id));
100+
}
101+
102+
// Load the user to schedule a device sync job
103+
let user = repo
104+
.user()
105+
.lookup(session.user_id)
106+
.await?
107+
.ok_or_else(|| RouteError::Internal("User not found for session".into()))?;
108+
109+
// Schedule a job to sync the devices of the user with the homeserver
110+
tracing::info!(user.id = %user.id, "Scheduling device sync job for user");
111+
repo.queue_job()
112+
.schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user))
113+
.await?;
114+
115+
// Finish the session
116+
let session = repo.compat_session().finish(&clock, session).await?;
117+
118+
// Get the SSO login info for the response
119+
let sso_login = repo.compat_sso_login().find_for_session(&session).await?;
120+
121+
repo.save().await?;
122+
123+
Ok(Json(SingleResponse::new(
124+
CompatSession::from((session, sso_login)),
125+
format!("/api/admin/v1/compat-sessions/{id}/finish"),
126+
)))
127+
}
128+
129+
#[cfg(test)]
130+
mod tests {
131+
use chrono::Duration;
132+
use hyper::{Request, StatusCode};
133+
use mas_data_model::{Clock as _, Device};
134+
use sqlx::PgPool;
135+
136+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
137+
138+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
139+
async fn test_finish_session(pool: PgPool) {
140+
setup();
141+
let mut state = TestState::from_pool(pool).await.unwrap();
142+
let token = state.token_with_scope("urn:mas:admin").await;
143+
let mut rng = state.rng();
144+
145+
// Provision a user and a compat session
146+
let mut repo = state.repository().await.unwrap();
147+
let user = repo
148+
.user()
149+
.add(&mut rng, &state.clock, "alice".to_owned())
150+
.await
151+
.unwrap();
152+
let device = Device::generate(&mut rng);
153+
let session = repo
154+
.compat_session()
155+
.add(&mut rng, &state.clock, &user, device, None, false, None)
156+
.await
157+
.unwrap();
158+
repo.save().await.unwrap();
159+
160+
let request = Request::post(format!(
161+
"/api/admin/v1/compat-sessions/{}/finish",
162+
session.id
163+
))
164+
.bearer(&token)
165+
.empty();
166+
let response = state.request(request).await;
167+
response.assert_status(StatusCode::OK);
168+
let body: serde_json::Value = response.json();
169+
170+
// The finished_at timestamp should be the same as the current time
171+
assert_eq!(
172+
body["data"]["attributes"]["finished_at"],
173+
serde_json::json!(state.clock.now())
174+
);
175+
}
176+
177+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
178+
async fn test_finish_already_finished_session(pool: PgPool) {
179+
setup();
180+
let mut state = TestState::from_pool(pool).await.unwrap();
181+
let token = state.token_with_scope("urn:mas:admin").await;
182+
let mut rng = state.rng();
183+
184+
// Provision a user and a compat session
185+
let mut repo = state.repository().await.unwrap();
186+
let user = repo
187+
.user()
188+
.add(&mut rng, &state.clock, "alice".to_owned())
189+
.await
190+
.unwrap();
191+
let device = Device::generate(&mut rng);
192+
let session = repo
193+
.compat_session()
194+
.add(&mut rng, &state.clock, &user, device, None, false, None)
195+
.await
196+
.unwrap();
197+
198+
// Finish the session first
199+
let session = repo
200+
.compat_session()
201+
.finish(&state.clock, session)
202+
.await
203+
.unwrap();
204+
205+
repo.save().await.unwrap();
206+
207+
// Move the clock forward
208+
state.clock.advance(Duration::try_minutes(1).unwrap());
209+
210+
let request = Request::post(format!(
211+
"/api/admin/v1/compat-sessions/{}/finish",
212+
session.id
213+
))
214+
.bearer(&token)
215+
.empty();
216+
let response = state.request(request).await;
217+
response.assert_status(StatusCode::BAD_REQUEST);
218+
let body: serde_json::Value = response.json();
219+
assert_eq!(
220+
body["errors"][0]["title"],
221+
format!(
222+
"Compatibility session with ID {} is already finished",
223+
session.id
224+
)
225+
);
226+
}
227+
228+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
229+
async fn test_finish_unknown_session(pool: PgPool) {
230+
setup();
231+
let mut state = TestState::from_pool(pool).await.unwrap();
232+
let token = state.token_with_scope("urn:mas:admin").await;
233+
234+
let request =
235+
Request::post("/api/admin/v1/compat-sessions/01040G2081040G2081040G2081/finish")
236+
.bearer(&token)
237+
.empty();
238+
let response = state.request(request).await;
239+
response.assert_status(StatusCode::NOT_FOUND);
240+
let body: serde_json::Value = response.json();
241+
assert_eq!(
242+
body["errors"][0]["title"],
243+
"Compatibility session with ID 01040G2081040G2081040G2081 not found"
244+
);
245+
}
246+
}

crates/handlers/src/admin/v1/compat_sessions/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
44
// Please see LICENSE files in the repository root for full details.
55

6+
mod finish;
67
mod get;
78
mod list;
89

910
pub use self::{
11+
finish::{doc as finish_doc, handler as finish},
1012
get::{doc as get_doc, handler as get},
1113
list::{doc as list_doc, handler as list},
1214
};

crates/handlers/src/admin/v1/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ where
5252
"/compat-sessions/{id}",
5353
get_with(self::compat_sessions::get, self::compat_sessions::get_doc),
5454
)
55+
.api_route(
56+
"/compat-sessions/{id}/finish",
57+
post_with(
58+
self::compat_sessions::finish,
59+
self::compat_sessions::finish_doc,
60+
),
61+
)
5562
.api_route(
5663
"/oauth2-sessions",
5764
get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc),

docs/api/spec.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,98 @@
342342
}
343343
}
344344
},
345+
"/api/admin/v1/compat-sessions/{id}/finish": {
346+
"post": {
347+
"tags": [
348+
"compat-session"
349+
],
350+
"summary": "Finish a compatibility session",
351+
"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.",
352+
"operationId": "finishCompatSession",
353+
"parameters": [
354+
{
355+
"in": "path",
356+
"name": "id",
357+
"required": true,
358+
"schema": {
359+
"title": "The ID of the resource",
360+
"$ref": "#/components/schemas/ULID"
361+
},
362+
"style": "simple"
363+
}
364+
],
365+
"responses": {
366+
"200": {
367+
"description": "Compatibility session was finished",
368+
"content": {
369+
"application/json": {
370+
"schema": {
371+
"$ref": "#/components/schemas/SingleResponse_for_CompatSession"
372+
},
373+
"example": {
374+
"data": {
375+
"type": "compat-session",
376+
"id": "02081040G2081040G2081040G2",
377+
"attributes": {
378+
"user_id": "01040G2081040G2081040G2081",
379+
"device_id": "FFGGHHIIJJ",
380+
"user_session_id": "0J289144GJ289144GJ289144GJ",
381+
"redirect_uri": null,
382+
"created_at": "1970-01-01T00:00:00Z",
383+
"user_agent": "Mozilla/5.0",
384+
"last_active_at": "1970-01-01T00:00:00Z",
385+
"last_active_ip": "1.2.3.4",
386+
"finished_at": "1970-01-01T00:00:00Z",
387+
"human_name": null
388+
},
389+
"links": {
390+
"self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2"
391+
}
392+
},
393+
"links": {
394+
"self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2/finish"
395+
}
396+
}
397+
}
398+
}
399+
},
400+
"400": {
401+
"description": "Session is already finished",
402+
"content": {
403+
"application/json": {
404+
"schema": {
405+
"$ref": "#/components/schemas/ErrorResponse"
406+
},
407+
"example": {
408+
"errors": [
409+
{
410+
"title": "Compatibility session with ID 00000000000000000000000000 is already finished"
411+
}
412+
]
413+
}
414+
}
415+
}
416+
},
417+
"404": {
418+
"description": "Compatibility session was not found",
419+
"content": {
420+
"application/json": {
421+
"schema": {
422+
"$ref": "#/components/schemas/ErrorResponse"
423+
},
424+
"example": {
425+
"errors": [
426+
{
427+
"title": "Compatibility session with ID 00000000000000000000000000 not found"
428+
}
429+
]
430+
}
431+
}
432+
}
433+
}
434+
}
435+
}
436+
},
345437
"/api/admin/v1/oauth2-sessions": {
346438
"get": {
347439
"tags": [

0 commit comments

Comments
 (0)