Skip to content

Commit 1f72136

Browse files
committed
Admin API to finish a compatibility session
1 parent 8bf890a commit 1f72136

File tree

4 files changed

+348
-0
lines changed

4 files changed

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

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)