From ee9a7ccaefcbafbf7ae98e61761c5ffaca30e0a3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:06:35 +0200 Subject: [PATCH] admin-api: filter user list by presence of active compat session Adds `filter[has-active-compat-session]=true|false` to `GET /api/admin/v1/users`. When `true` (resp. `false`), only users with (resp. without) at least one active (non-finished) compatibility session are returned. --- crates/handlers/src/admin/v1/users/list.rs | 87 ++++++++++++++++++++++ crates/storage-pg/src/user/tests.rs | 2 +- docs/api/spec.json | 20 +++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index ee10a4102..f1703498f 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -85,6 +85,11 @@ pub struct FilterParams { #[serde(default, rename = "filter[active-oauth2-client]")] #[schemars(with = "Vec")] active_oauth2_client: Vec, + + /// Retrieve users which have (or don't have) at least one active + /// (non-finished) compatibility session. + #[serde(rename = "filter[has-active-compat-session]")] + has_active_compat_session: Option, } impl std::fmt::Display for FilterParams { @@ -111,6 +116,10 @@ impl std::fmt::Display for FilterParams { write!(f, "{sep}filter[active-oauth2-client]={client}")?; sep = '&'; } + if let Some(has) = self.has_active_compat_session { + write!(f, "{sep}filter[has-active-compat-session]={has}")?; + sep = '&'; + } let _ = sep; Ok(()) @@ -228,6 +237,11 @@ pub async fn handler( filter.with_active_oauth2_session_for_any_of_clients(¶ms.active_oauth2_client) }; + let filter = match params.has_active_compat_session { + Some(has) => filter.with_active_compat_session(has), + None => filter, + }; + let response = match include_count { IncludeCount::True => { let page = repo.user().list(filter, pagination).await?; @@ -250,8 +264,10 @@ pub async fn handler( #[cfg(test)] mod tests { use hyper::{Request, StatusCode}; + use mas_data_model::Device; use mas_storage::{ RepositoryAccess, + compat::CompatSessionRepository, oauth2::{OAuth2ClientRepository, OAuth2SessionRepository}, }; use oauth2_types::{ @@ -612,4 +628,75 @@ mod tests { let response = state.request(request).await; response.assert_status(StatusCode::NOT_FOUND); } + + /// Test the `filter[has-active-compat-session]` filter in both + /// directions. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_users_filter_has_active_compat_session(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + + // Alice has an active compat session. + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + repo.compat_session() + .add(&mut rng, &state.clock, &alice, device, None, false, None) + .await + .unwrap(); + + // Bob has a finished compat session. + let bob = repo + .user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + let device = Device::generate(&mut rng); + let bob_session = repo + .compat_session() + .add(&mut rng, &state.clock, &bob, device, None, false, None) + .await + .unwrap(); + repo.compat_session() + .finish(&state.clock, bob_session) + .await + .unwrap(); + + // Carol has no compat session. + repo.user() + .add(&mut rng, &state.clock, "carol".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // has-active-compat-session=true -> 1 (alice) + let request = + Request::get("/api/admin/v1/users?count=only&filter[has-active-compat-session]=true") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["meta"]["count"], 1); + + // has-active-compat-session=false -> 2 (bob + carol). The admin + // token is created via client-credentials and does not provision a + // user. + let request = + Request::get("/api/admin/v1/users?count=only&filter[has-active-compat-session]=false") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["meta"]["count"], 2); + } } diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 87dcb640a..f42cca12d 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -841,7 +841,7 @@ async fn test_user_session(pool: PgPool) { /// Test [`UserFilter::with_active_oauth2_session_for_any_of_clients`]. /// -/// Sets up three users (A, B, C) with various OAuth2 session states across +/// Sets up three users (A, B, C) with various `OAuth2` session states across /// three clients, and asserts that filtering on `[client_x, client_y]` /// returns users that have an ACTIVE session for *any* of those clients /// (OR semantics). diff --git a/docs/api/spec.json b/docs/api/spec.json index 86d29ad60..311a5eeab 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -1966,6 +1966,19 @@ "default": [] }, "style": "form" + }, + { + "in": "query", + "name": "filter[has-active-compat-session]", + "description": "Retrieve users which have (or don't have) at least one active\n (non-finished) compatibility session.", + "schema": { + "description": "Retrieve users which have (or don't have) at least one active\n (non-finished) compatibility session.", + "type": [ + "boolean", + "null" + ] + }, + "style": "form" } ], "responses": { @@ -6183,6 +6196,13 @@ "$ref": "#/components/schemas/ULID" }, "default": [] + }, + "filter[has-active-compat-session]": { + "description": "Retrieve users which have (or don't have) at least one active\n (non-finished) compatibility session.", + "type": [ + "boolean", + "null" + ] } } },