mas_handlers/admin/v1/compat_sessions/
list.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{
8    Json,
9    extract::{Query, rejection::QueryRejection},
10    response::IntoResponse,
11};
12use axum_macros::FromRequestParts;
13use hyper::StatusCode;
14use mas_axum_utils::record_error;
15use mas_storage::{Page, compat::CompatSessionFilter};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use ulid::Ulid;
19
20use crate::{
21    admin::{
22        call_context::CallContext,
23        model::{CompatSession, Resource},
24        params::Pagination,
25        response::{ErrorResponse, PaginatedResponse},
26    },
27    impl_from_error_for_route,
28};
29
30#[derive(Deserialize, JsonSchema, Clone, Copy)]
31#[serde(rename_all = "snake_case")]
32enum CompatSessionStatus {
33    Active,
34    Finished,
35}
36
37impl std::fmt::Display for CompatSessionStatus {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Active => write!(f, "active"),
41            Self::Finished => write!(f, "finished"),
42        }
43    }
44}
45
46#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
47#[serde(rename = "CompatSessionFilter")]
48#[aide(input_with = "Query<FilterParams>")]
49#[from_request(via(Query), rejection(RouteError))]
50pub struct FilterParams {
51    /// Retrieve the items for the given user
52    #[serde(rename = "filter[user]")]
53    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
54    user: Option<Ulid>,
55
56    /// Retrieve the items started from the given browser session
57    #[serde(rename = "filter[user-session]")]
58    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
59    user_session: Option<Ulid>,
60
61    /// Retrieve the items with the given status
62    ///
63    /// Defaults to retrieve all sessions, including finished ones.
64    ///
65    /// * `active`: Only retrieve active sessions
66    ///
67    /// * `finished`: Only retrieve finished sessions
68    #[serde(rename = "filter[status]")]
69    status: Option<CompatSessionStatus>,
70}
71
72impl std::fmt::Display for FilterParams {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        let mut sep = '?';
75
76        if let Some(user) = self.user {
77            write!(f, "{sep}filter[user]={user}")?;
78            sep = '&';
79        }
80
81        if let Some(user_session) = self.user_session {
82            write!(f, "{sep}filter[user-session]={user_session}")?;
83            sep = '&';
84        }
85
86        if let Some(status) = self.status {
87            write!(f, "{sep}filter[status]={status}")?;
88            sep = '&';
89        }
90
91        let _ = sep;
92        Ok(())
93    }
94}
95
96#[derive(Debug, thiserror::Error, OperationIo)]
97#[aide(output_with = "Json<ErrorResponse>")]
98pub enum RouteError {
99    #[error(transparent)]
100    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
101
102    #[error("User ID {0} not found")]
103    UserNotFound(Ulid),
104
105    #[error("User session ID {0} not found")]
106    UserSessionNotFound(Ulid),
107
108    #[error("Invalid filter parameters")]
109    InvalidFilter(#[from] QueryRejection),
110}
111
112impl_from_error_for_route!(mas_storage::RepositoryError);
113
114impl IntoResponse for RouteError {
115    fn into_response(self) -> axum::response::Response {
116        let error = ErrorResponse::from_error(&self);
117        let sentry_event_id = record_error!(self, RouteError::Internal(_));
118        let status = match &self {
119            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
120            Self::UserNotFound(_) | Self::UserSessionNotFound(_) => StatusCode::NOT_FOUND,
121            Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
122        };
123
124        (status, sentry_event_id, Json(error)).into_response()
125    }
126}
127
128pub fn doc(operation: TransformOperation) -> TransformOperation {
129    operation
130        .id("listCompatSessions")
131        .summary("List compatibility sessions")
132        .description("Retrieve a list of compatibility sessions.
133Note that by default, all sessions, including finished ones are returned, with the oldest first.
134Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
135        .tag("compat-session")
136        .response_with::<200, Json<PaginatedResponse<CompatSession>>, _>(|t| {
137            let sessions = CompatSession::samples();
138            let pagination = mas_storage::Pagination::first(sessions.len());
139            let page = Page {
140                edges: sessions.into(),
141                has_next_page: true,
142                has_previous_page: false,
143            };
144
145            t.description("Paginated response of compatibility sessions")
146                .example(PaginatedResponse::new(
147                    page,
148                    pagination,
149                    42,
150                    CompatSession::PATH,
151                ))
152        })
153        .response_with::<404, RouteError, _>(|t| {
154            let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
155            t.description("User was not found").example(response)
156        })
157}
158
159#[tracing::instrument(name = "handler.admin.v1.compat_sessions.list", skip_all)]
160pub async fn handler(
161    CallContext { mut repo, .. }: CallContext,
162    Pagination(pagination): Pagination,
163    params: FilterParams,
164) -> Result<Json<PaginatedResponse<CompatSession>>, RouteError> {
165    let base = format!("{path}{params}", path = CompatSession::PATH);
166    let filter = CompatSessionFilter::default();
167
168    // Load the user from the filter
169    let user = if let Some(user_id) = params.user {
170        let user = repo
171            .user()
172            .lookup(user_id)
173            .await?
174            .ok_or(RouteError::UserNotFound(user_id))?;
175
176        Some(user)
177    } else {
178        None
179    };
180
181    let filter = match &user {
182        Some(user) => filter.for_user(user),
183        None => filter,
184    };
185
186    let user_session = if let Some(user_session_id) = params.user_session {
187        let user_session = repo
188            .browser_session()
189            .lookup(user_session_id)
190            .await?
191            .ok_or(RouteError::UserSessionNotFound(user_session_id))?;
192
193        Some(user_session)
194    } else {
195        None
196    };
197
198    let filter = match &user_session {
199        Some(user_session) => filter.for_browser_session(user_session),
200        None => filter,
201    };
202
203    let filter = match params.status {
204        Some(CompatSessionStatus::Active) => filter.active_only(),
205        Some(CompatSessionStatus::Finished) => filter.finished_only(),
206        None => filter,
207    };
208
209    let page = repo.compat_session().list(filter, pagination).await?;
210    let count = repo.compat_session().count(filter).await?;
211
212    Ok(Json(PaginatedResponse::new(
213        page.map(CompatSession::from),
214        pagination,
215        count,
216        &base,
217    )))
218}
219
220#[cfg(test)]
221mod tests {
222    use chrono::Duration;
223    use hyper::{Request, StatusCode};
224    use insta::assert_json_snapshot;
225    use mas_data_model::Device;
226    use sqlx::PgPool;
227
228    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
229
230    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
231    async fn test_compat_session_list(pool: PgPool) {
232        setup();
233        let mut state = TestState::from_pool(pool).await.unwrap();
234        let token = state.token_with_scope("urn:mas:admin").await;
235        let mut rng = state.rng();
236
237        // Provision two users, one compat session for each, and finish one of them
238        let mut repo = state.repository().await.unwrap();
239        let alice = repo
240            .user()
241            .add(&mut rng, &state.clock, "alice".to_owned())
242            .await
243            .unwrap();
244        state.clock.advance(Duration::minutes(1));
245
246        let bob = repo
247            .user()
248            .add(&mut rng, &state.clock, "bob".to_owned())
249            .await
250            .unwrap();
251
252        let device = Device::generate(&mut rng);
253        repo.compat_session()
254            .add(&mut rng, &state.clock, &alice, device, None, false)
255            .await
256            .unwrap();
257        let device = Device::generate(&mut rng);
258
259        state.clock.advance(Duration::minutes(1));
260
261        let session = repo
262            .compat_session()
263            .add(&mut rng, &state.clock, &bob, device, None, false)
264            .await
265            .unwrap();
266        state.clock.advance(Duration::minutes(1));
267        repo.compat_session()
268            .finish(&state.clock, session)
269            .await
270            .unwrap();
271        repo.save().await.unwrap();
272
273        let request = Request::get("/api/admin/v1/compat-sessions")
274            .bearer(&token)
275            .empty();
276        let response = state.request(request).await;
277        response.assert_status(StatusCode::OK);
278        let body: serde_json::Value = response.json();
279        assert_json_snapshot!(body, @r###"
280        {
281          "meta": {
282            "count": 2
283          },
284          "data": [
285            {
286              "type": "compat-session",
287              "id": "01FSHNB530AAPR7PEV8KNBZD5Y",
288              "attributes": {
289                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
290                "device_id": "LoieH5Iecx",
291                "user_session_id": null,
292                "redirect_uri": null,
293                "created_at": "2022-01-16T14:41:00Z",
294                "user_agent": null,
295                "last_active_at": null,
296                "last_active_ip": null,
297                "finished_at": null
298              },
299              "links": {
300                "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
301              }
302            },
303            {
304              "type": "compat-session",
305              "id": "01FSHNCZP0PPF7X0EVMJNECPZW",
306              "attributes": {
307                "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
308                "device_id": "ZXyvelQWW9",
309                "user_session_id": null,
310                "redirect_uri": null,
311                "created_at": "2022-01-16T14:42:00Z",
312                "user_agent": null,
313                "last_active_at": null,
314                "last_active_ip": null,
315                "finished_at": "2022-01-16T14:43:00Z"
316              },
317              "links": {
318                "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
319              }
320            }
321          ],
322          "links": {
323            "self": "/api/admin/v1/compat-sessions?page[first]=10",
324            "first": "/api/admin/v1/compat-sessions?page[first]=10",
325            "last": "/api/admin/v1/compat-sessions?page[last]=10"
326          }
327        }
328        "###);
329
330        // Filter by user
331        let request = Request::get(format!(
332            "/api/admin/v1/compat-sessions?filter[user]={}",
333            alice.id
334        ))
335        .bearer(&token)
336        .empty();
337        let response = state.request(request).await;
338        response.assert_status(StatusCode::OK);
339        let body: serde_json::Value = response.json();
340        assert_json_snapshot!(body, @r###"
341        {
342          "meta": {
343            "count": 1
344          },
345          "data": [
346            {
347              "type": "compat-session",
348              "id": "01FSHNB530AAPR7PEV8KNBZD5Y",
349              "attributes": {
350                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
351                "device_id": "LoieH5Iecx",
352                "user_session_id": null,
353                "redirect_uri": null,
354                "created_at": "2022-01-16T14:41:00Z",
355                "user_agent": null,
356                "last_active_at": null,
357                "last_active_ip": null,
358                "finished_at": null
359              },
360              "links": {
361                "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
362              }
363            }
364          ],
365          "links": {
366            "self": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
367            "first": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
368            "last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
369          }
370        }
371        "###);
372
373        // Filter by status (active)
374        let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active")
375            .bearer(&token)
376            .empty();
377        let response = state.request(request).await;
378        response.assert_status(StatusCode::OK);
379        let body: serde_json::Value = response.json();
380        assert_json_snapshot!(body, @r###"
381        {
382          "meta": {
383            "count": 1
384          },
385          "data": [
386            {
387              "type": "compat-session",
388              "id": "01FSHNB530AAPR7PEV8KNBZD5Y",
389              "attributes": {
390                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
391                "device_id": "LoieH5Iecx",
392                "user_session_id": null,
393                "redirect_uri": null,
394                "created_at": "2022-01-16T14:41:00Z",
395                "user_agent": null,
396                "last_active_at": null,
397                "last_active_ip": null,
398                "finished_at": null
399              },
400              "links": {
401                "self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
402              }
403            }
404          ],
405          "links": {
406            "self": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10",
407            "first": "/api/admin/v1/compat-sessions?filter[status]=active&page[first]=10",
408            "last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10"
409          }
410        }
411        "###);
412
413        // Filter by status (finished)
414        let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished")
415            .bearer(&token)
416            .empty();
417        let response = state.request(request).await;
418        response.assert_status(StatusCode::OK);
419        let body: serde_json::Value = response.json();
420        assert_json_snapshot!(body, @r###"
421        {
422          "meta": {
423            "count": 1
424          },
425          "data": [
426            {
427              "type": "compat-session",
428              "id": "01FSHNCZP0PPF7X0EVMJNECPZW",
429              "attributes": {
430                "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
431                "device_id": "ZXyvelQWW9",
432                "user_session_id": null,
433                "redirect_uri": null,
434                "created_at": "2022-01-16T14:42:00Z",
435                "user_agent": null,
436                "last_active_at": null,
437                "last_active_ip": null,
438                "finished_at": "2022-01-16T14:43:00Z"
439              },
440              "links": {
441                "self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
442              }
443            }
444          ],
445          "links": {
446            "self": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10",
447            "first": "/api/admin/v1/compat-sessions?filter[status]=finished&page[first]=10",
448            "last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10"
449          }
450        }
451        "###);
452    }
453}