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