Skip to content

Commit defb2cf

Browse files
committed
Admin API to finish a user session
1 parent 821e776 commit defb2cf

File tree

4 files changed

+310
-0
lines changed

4 files changed

+310
-0
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ where
130130
"/user-sessions/{id}",
131131
get_with(self::user_sessions::get, self::user_sessions::get_doc),
132132
)
133+
.api_route(
134+
"/user-sessions/{id}/finish",
135+
post_with(self::user_sessions::finish, self::user_sessions::finish_doc),
136+
)
133137
.api_route(
134138
"/user-registration-tokens",
135139
get_with(
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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::{OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use mas_axum_utils::record_error;
10+
use ulid::Ulid;
11+
12+
use crate::{
13+
admin::{
14+
call_context::CallContext,
15+
model::{Resource, UserSession},
16+
params::UlidPathParam,
17+
response::{ErrorResponse, SingleResponse},
18+
},
19+
impl_from_error_for_route,
20+
};
21+
22+
#[derive(Debug, thiserror::Error, OperationIo)]
23+
#[aide(output_with = "Json<ErrorResponse>")]
24+
pub enum RouteError {
25+
#[error(transparent)]
26+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
27+
28+
#[error("User session with ID {0} not found")]
29+
NotFound(Ulid),
30+
31+
#[error("User session with ID {0} is already finished")]
32+
AlreadyFinished(Ulid),
33+
}
34+
35+
impl_from_error_for_route!(mas_storage::RepositoryError);
36+
37+
impl IntoResponse for RouteError {
38+
fn into_response(self) -> axum::response::Response {
39+
let error = ErrorResponse::from_error(&self);
40+
let sentry_event_id = record_error!(self, Self::Internal(_));
41+
let status = match self {
42+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
43+
Self::NotFound(_) => StatusCode::NOT_FOUND,
44+
Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST,
45+
};
46+
(status, sentry_event_id, Json(error)).into_response()
47+
}
48+
}
49+
50+
pub fn doc(operation: TransformOperation) -> TransformOperation {
51+
operation
52+
.id("finishUserSession")
53+
.summary("Finish a user session")
54+
.description(
55+
"Calling this endpoint will finish the user session, preventing any further use.",
56+
)
57+
.tag("user-session")
58+
.response_with::<200, Json<SingleResponse<UserSession>>, _>(|t| {
59+
// Get the finished session sample
60+
let [_, _, finished_session] = UserSession::samples();
61+
let id = finished_session.id();
62+
let response = SingleResponse::new(
63+
finished_session,
64+
format!("/api/admin/v1/user-sessions/{id}/finish"),
65+
);
66+
t.description("User session was finished").example(response)
67+
})
68+
.response_with::<400, RouteError, _>(|t| {
69+
let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil()));
70+
t.description("Session is already finished")
71+
.example(response)
72+
})
73+
.response_with::<404, RouteError, _>(|t| {
74+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
75+
t.description("User session was not found")
76+
.example(response)
77+
})
78+
}
79+
80+
#[tracing::instrument(name = "handler.admin.v1.user_sessions.finish", skip_all)]
81+
pub async fn handler(
82+
CallContext {
83+
mut repo, clock, ..
84+
}: CallContext,
85+
id: UlidPathParam,
86+
) -> Result<Json<SingleResponse<UserSession>>, RouteError> {
87+
let id = *id;
88+
let session = repo
89+
.browser_session()
90+
.lookup(id)
91+
.await?
92+
.ok_or(RouteError::NotFound(id))?;
93+
94+
// Check if the session is already finished
95+
if session.finished_at.is_some() {
96+
return Err(RouteError::AlreadyFinished(id));
97+
}
98+
99+
// Finish the session
100+
let session = repo.browser_session().finish(&clock, session).await?;
101+
102+
repo.save().await?;
103+
104+
Ok(Json(SingleResponse::new(
105+
UserSession::from(session),
106+
format!("/api/admin/v1/user-sessions/{id}/finish"),
107+
)))
108+
}
109+
110+
#[cfg(test)]
111+
mod tests {
112+
use chrono::Duration;
113+
use hyper::{Request, StatusCode};
114+
use mas_data_model::Clock as _;
115+
use sqlx::PgPool;
116+
117+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
118+
119+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
120+
async fn test_finish_session(pool: PgPool) {
121+
setup();
122+
let mut state = TestState::from_pool(pool).await.unwrap();
123+
let token = state.token_with_scope("urn:mas:admin").await;
124+
let mut rng = state.rng();
125+
126+
// Provision a user and a user session
127+
let mut repo = state.repository().await.unwrap();
128+
let user = repo
129+
.user()
130+
.add(&mut rng, &state.clock, "alice".to_owned())
131+
.await
132+
.unwrap();
133+
let session = repo
134+
.browser_session()
135+
.add(&mut rng, &state.clock, &user, None)
136+
.await
137+
.unwrap();
138+
repo.save().await.unwrap();
139+
140+
let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id))
141+
.bearer(&token)
142+
.empty();
143+
let response = state.request(request).await;
144+
response.assert_status(StatusCode::OK);
145+
let body: serde_json::Value = response.json();
146+
147+
// The finished_at timestamp should be the same as the current time
148+
assert_eq!(
149+
body["data"]["attributes"]["finished_at"],
150+
serde_json::json!(state.clock.now())
151+
);
152+
}
153+
154+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
155+
async fn test_finish_already_finished_session(pool: PgPool) {
156+
setup();
157+
let mut state = TestState::from_pool(pool).await.unwrap();
158+
let token = state.token_with_scope("urn:mas:admin").await;
159+
let mut rng = state.rng();
160+
161+
// Provision a user and a user session
162+
let mut repo = state.repository().await.unwrap();
163+
let user = repo
164+
.user()
165+
.add(&mut rng, &state.clock, "alice".to_owned())
166+
.await
167+
.unwrap();
168+
let session = repo
169+
.browser_session()
170+
.add(&mut rng, &state.clock, &user, None)
171+
.await
172+
.unwrap();
173+
174+
// Finish the session first
175+
let session = repo
176+
.browser_session()
177+
.finish(&state.clock, session)
178+
.await
179+
.unwrap();
180+
181+
repo.save().await.unwrap();
182+
183+
// Move the clock forward
184+
state.clock.advance(Duration::try_minutes(1).unwrap());
185+
186+
let request = Request::post(format!("/api/admin/v1/user-sessions/{}/finish", session.id))
187+
.bearer(&token)
188+
.empty();
189+
let response = state.request(request).await;
190+
response.assert_status(StatusCode::BAD_REQUEST);
191+
let body: serde_json::Value = response.json();
192+
assert_eq!(
193+
body["errors"][0]["title"],
194+
format!("User session with ID {} is already finished", session.id)
195+
);
196+
}
197+
198+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
199+
async fn test_finish_unknown_session(pool: PgPool) {
200+
setup();
201+
let mut state = TestState::from_pool(pool).await.unwrap();
202+
let token = state.token_with_scope("urn:mas:admin").await;
203+
204+
let request =
205+
Request::post("/api/admin/v1/user-sessions/01040G2081040G2081040G2081/finish")
206+
.bearer(&token)
207+
.empty();
208+
let response = state.request(request).await;
209+
response.assert_status(StatusCode::NOT_FOUND);
210+
let body: serde_json::Value = response.json();
211+
assert_eq!(
212+
body["errors"][0]["title"],
213+
"User session with ID 01040G2081040G2081040G2081 not found"
214+
);
215+
}
216+
}

crates/handlers/src/admin/v1/user_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
};

docs/api/spec.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2399,6 +2399,94 @@
23992399
}
24002400
}
24012401
},
2402+
"/api/admin/v1/user-sessions/{id}/finish": {
2403+
"post": {
2404+
"tags": [
2405+
"user-session"
2406+
],
2407+
"summary": "Finish a user session",
2408+
"description": "Calling this endpoint will finish the user session, preventing any further use.",
2409+
"operationId": "finishUserSession",
2410+
"parameters": [
2411+
{
2412+
"in": "path",
2413+
"name": "id",
2414+
"required": true,
2415+
"schema": {
2416+
"title": "The ID of the resource",
2417+
"$ref": "#/components/schemas/ULID"
2418+
},
2419+
"style": "simple"
2420+
}
2421+
],
2422+
"responses": {
2423+
"200": {
2424+
"description": "User session was finished",
2425+
"content": {
2426+
"application/json": {
2427+
"schema": {
2428+
"$ref": "#/components/schemas/SingleResponse_for_UserSession"
2429+
},
2430+
"example": {
2431+
"data": {
2432+
"type": "user-session",
2433+
"id": "030C1G60R30C1G60R30C1G60R3",
2434+
"attributes": {
2435+
"created_at": "1970-01-01T00:00:00Z",
2436+
"finished_at": "1970-01-01T00:00:00Z",
2437+
"user_id": "040G2081040G2081040G208104",
2438+
"user_agent": "Mozilla/5.0",
2439+
"last_active_at": "1970-01-01T00:00:00Z",
2440+
"last_active_ip": "127.0.0.1"
2441+
},
2442+
"links": {
2443+
"self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3"
2444+
}
2445+
},
2446+
"links": {
2447+
"self": "/api/admin/v1/user-sessions/030C1G60R30C1G60R30C1G60R3/finish"
2448+
}
2449+
}
2450+
}
2451+
}
2452+
},
2453+
"400": {
2454+
"description": "Session is already finished",
2455+
"content": {
2456+
"application/json": {
2457+
"schema": {
2458+
"$ref": "#/components/schemas/ErrorResponse"
2459+
},
2460+
"example": {
2461+
"errors": [
2462+
{
2463+
"title": "User session with ID 00000000000000000000000000 is already finished"
2464+
}
2465+
]
2466+
}
2467+
}
2468+
}
2469+
},
2470+
"404": {
2471+
"description": "User session was not found",
2472+
"content": {
2473+
"application/json": {
2474+
"schema": {
2475+
"$ref": "#/components/schemas/ErrorResponse"
2476+
},
2477+
"example": {
2478+
"errors": [
2479+
{
2480+
"title": "User session with ID 00000000000000000000000000 not found"
2481+
}
2482+
]
2483+
}
2484+
}
2485+
}
2486+
}
2487+
}
2488+
}
2489+
},
24022490
"/api/admin/v1/user-registration-tokens": {
24032491
"get": {
24042492
"tags": [

0 commit comments

Comments
 (0)