mas_handlers/admin/v1/upstream_oauth_links/
list.rs

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
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{Json, response::IntoResponse};
8use axum_extra::extract::{Query, QueryRejection};
9use axum_macros::FromRequestParts;
10use hyper::StatusCode;
11use mas_axum_utils::record_error;
12use mas_storage::{Page, upstream_oauth2::UpstreamOAuthLinkFilter};
13use schemars::JsonSchema;
14use serde::Deserialize;
15use ulid::Ulid;
16
17use crate::{
18    admin::{
19        call_context::CallContext,
20        model::{Resource, UpstreamOAuthLink},
21        params::{IncludeCount, Pagination},
22        response::{ErrorResponse, PaginatedResponse},
23    },
24    impl_from_error_for_route,
25};
26
27#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
28#[serde(rename = "UpstreamOAuthLinkFilter")]
29#[aide(input_with = "Query<FilterParams>")]
30#[from_request(via(Query), rejection(RouteError))]
31pub struct FilterParams {
32    /// Retrieve the items for the given user
33    #[serde(rename = "filter[user]")]
34    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
35    user: Option<Ulid>,
36
37    /// Retrieve the items for the given provider
38    #[serde(rename = "filter[provider]")]
39    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
40    provider: Option<Ulid>,
41
42    /// Retrieve the items with the given subject
43    #[serde(rename = "filter[subject]")]
44    subject: Option<String>,
45}
46
47impl std::fmt::Display for FilterParams {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        let mut sep = '?';
50
51        if let Some(user) = self.user {
52            write!(f, "{sep}filter[user]={user}")?;
53            sep = '&';
54        }
55
56        if let Some(provider) = self.provider {
57            write!(f, "{sep}filter[provider]={provider}")?;
58            sep = '&';
59        }
60
61        if let Some(subject) = &self.subject {
62            write!(f, "{sep}filter[subject]={subject}")?;
63            sep = '&';
64        }
65
66        let _ = sep;
67        Ok(())
68    }
69}
70
71#[derive(Debug, thiserror::Error, OperationIo)]
72#[aide(output_with = "Json<ErrorResponse>")]
73pub enum RouteError {
74    #[error(transparent)]
75    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
76
77    #[error("User ID {0} not found")]
78    UserNotFound(Ulid),
79
80    #[error("Provider ID {0} not found")]
81    ProviderNotFound(Ulid),
82
83    #[error("Invalid filter parameters")]
84    InvalidFilter(#[from] QueryRejection),
85}
86
87impl_from_error_for_route!(mas_storage::RepositoryError);
88
89impl IntoResponse for RouteError {
90    fn into_response(self) -> axum::response::Response {
91        let error = ErrorResponse::from_error(&self);
92        let sentry_event_id = record_error!(self, Self::Internal(_));
93        let status = match self {
94            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
95            Self::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND,
96            Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
97        };
98        (status, sentry_event_id, Json(error)).into_response()
99    }
100}
101
102pub fn doc(operation: TransformOperation) -> TransformOperation {
103    operation
104        .id("listUpstreamOAuthLinks")
105        .summary("List upstream OAuth 2.0 links")
106        .description("Retrieve a list of upstream OAuth 2.0 links.")
107        .tag("upstream-oauth-link")
108        .response_with::<200, Json<PaginatedResponse<UpstreamOAuthLink>>, _>(|t| {
109            let links = UpstreamOAuthLink::samples();
110            let pagination = mas_storage::Pagination::first(links.len());
111            let page = Page {
112                edges: links
113                    .into_iter()
114                    .map(|node| mas_storage::pagination::Edge {
115                        cursor: node.id(),
116                        node,
117                    })
118                    .collect(),
119                has_next_page: true,
120                has_previous_page: false,
121            };
122
123            t.description("Paginated response of upstream OAuth 2.0 links")
124                .example(PaginatedResponse::for_page(
125                    page,
126                    pagination,
127                    Some(42),
128                    UpstreamOAuthLink::PATH,
129                ))
130        })
131        .response_with::<404, RouteError, _>(|t| {
132            let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
133            t.description("User or provider was not found")
134                .example(response)
135        })
136}
137
138#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all)]
139pub async fn handler(
140    CallContext { mut repo, .. }: CallContext,
141    Pagination(pagination, include_count): Pagination,
142    params: FilterParams,
143) -> Result<Json<PaginatedResponse<UpstreamOAuthLink>>, RouteError> {
144    let base = format!("{path}{params}", path = UpstreamOAuthLink::PATH);
145    let base = include_count.add_to_base(&base);
146    let filter = UpstreamOAuthLinkFilter::default();
147
148    // Load the user from the filter
149    let maybe_user = if let Some(user_id) = params.user {
150        let user = repo
151            .user()
152            .lookup(user_id)
153            .await?
154            .ok_or(RouteError::UserNotFound(user_id))?;
155        Some(user)
156    } else {
157        None
158    };
159
160    let filter = if let Some(user) = &maybe_user {
161        filter.for_user(user)
162    } else {
163        filter
164    };
165
166    // Load the provider from the filter
167    let maybe_provider = if let Some(provider_id) = params.provider {
168        let provider = repo
169            .upstream_oauth_provider()
170            .lookup(provider_id)
171            .await?
172            .ok_or(RouteError::ProviderNotFound(provider_id))?;
173        Some(provider)
174    } else {
175        None
176    };
177
178    let filter = if let Some(provider) = &maybe_provider {
179        filter.for_provider(provider)
180    } else {
181        filter
182    };
183
184    let filter = if let Some(subject) = &params.subject {
185        filter.for_subject(subject)
186    } else {
187        filter
188    };
189
190    let response = match include_count {
191        IncludeCount::True => {
192            let page = repo
193                .upstream_oauth_link()
194                .list(filter, pagination)
195                .await?
196                .map(UpstreamOAuthLink::from);
197            let count = repo.upstream_oauth_link().count(filter).await?;
198            PaginatedResponse::for_page(page, pagination, Some(count), &base)
199        }
200        IncludeCount::False => {
201            let page = repo
202                .upstream_oauth_link()
203                .list(filter, pagination)
204                .await?
205                .map(UpstreamOAuthLink::from);
206            PaginatedResponse::for_page(page, pagination, None, &base)
207        }
208        IncludeCount::Only => {
209            let count = repo.upstream_oauth_link().count(filter).await?;
210            PaginatedResponse::for_count_only(count, &base)
211        }
212    };
213
214    Ok(Json(response))
215}
216
217#[cfg(test)]
218mod tests {
219    use hyper::{Request, StatusCode};
220    use insta::assert_json_snapshot;
221    use sqlx::PgPool;
222
223    use super::super::test_utils;
224    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
225
226    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
227    async fn test_list(pool: PgPool) {
228        setup();
229        let mut state = TestState::from_pool(pool).await.unwrap();
230        let token = state.token_with_scope("urn:mas:admin").await;
231        let mut rng = state.rng();
232
233        // Provision users and providers
234        let mut repo = state.repository().await.unwrap();
235        let alice = repo
236            .user()
237            .add(&mut rng, &state.clock, "alice".to_owned())
238            .await
239            .unwrap();
240        let bob = repo
241            .user()
242            .add(&mut rng, &state.clock, "bob".to_owned())
243            .await
244            .unwrap();
245        let provider1 = repo
246            .upstream_oauth_provider()
247            .add(
248                &mut rng,
249                &state.clock,
250                test_utils::oidc_provider_params("acme"),
251            )
252            .await
253            .unwrap();
254        let provider2 = repo
255            .upstream_oauth_provider()
256            .add(
257                &mut rng,
258                &state.clock,
259                test_utils::oidc_provider_params("example"),
260            )
261            .await
262            .unwrap();
263
264        // Create some links
265        let link1 = repo
266            .upstream_oauth_link()
267            .add(
268                &mut rng,
269                &state.clock,
270                &provider1,
271                "subject1".to_owned(),
272                Some("alice@acme".to_owned()),
273            )
274            .await
275            .unwrap();
276        repo.upstream_oauth_link()
277            .associate_to_user(&link1, &alice)
278            .await
279            .unwrap();
280        let link2 = repo
281            .upstream_oauth_link()
282            .add(
283                &mut rng,
284                &state.clock,
285                &provider2,
286                "subject2".to_owned(),
287                Some("alice@example".to_owned()),
288            )
289            .await
290            .unwrap();
291        repo.upstream_oauth_link()
292            .associate_to_user(&link2, &alice)
293            .await
294            .unwrap();
295        let link3 = repo
296            .upstream_oauth_link()
297            .add(
298                &mut rng,
299                &state.clock,
300                &provider1,
301                "subject3".to_owned(),
302                Some("bob@acme".to_owned()),
303            )
304            .await
305            .unwrap();
306        repo.upstream_oauth_link()
307            .associate_to_user(&link3, &bob)
308            .await
309            .unwrap();
310
311        repo.save().await.unwrap();
312
313        let request = Request::get("/api/admin/v1/upstream-oauth-links")
314            .bearer(&token)
315            .empty();
316        let response = state.request(request).await;
317        response.assert_status(StatusCode::OK);
318        let body: serde_json::Value = response.json();
319        assert_json_snapshot!(body, @r#"
320        {
321          "meta": {
322            "count": 3
323          },
324          "data": [
325            {
326              "type": "upstream-oauth-link",
327              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
328              "attributes": {
329                "created_at": "2022-01-16T14:40:00Z",
330                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
331                "subject": "subject1",
332                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
333                "human_account_name": "alice@acme"
334              },
335              "links": {
336                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
337              },
338              "meta": {
339                "page": {
340                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
341                }
342              }
343            },
344            {
345              "type": "upstream-oauth-link",
346              "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4",
347              "attributes": {
348                "created_at": "2022-01-16T14:40:00Z",
349                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
350                "subject": "subject3",
351                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
352                "human_account_name": "bob@acme"
353              },
354              "links": {
355                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4"
356              },
357              "meta": {
358                "page": {
359                  "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4"
360                }
361              }
362            },
363            {
364              "type": "upstream-oauth-link",
365              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
366              "attributes": {
367                "created_at": "2022-01-16T14:40:00Z",
368                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
369                "subject": "subject2",
370                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
371                "human_account_name": "alice@example"
372              },
373              "links": {
374                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
375              },
376              "meta": {
377                "page": {
378                  "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07"
379                }
380              }
381            }
382          ],
383          "links": {
384            "self": "/api/admin/v1/upstream-oauth-links?page[first]=10",
385            "first": "/api/admin/v1/upstream-oauth-links?page[first]=10",
386            "last": "/api/admin/v1/upstream-oauth-links?page[last]=10"
387          }
388        }
389        "#);
390
391        // Filter by user ID
392        let request = Request::get(format!(
393            "/api/admin/v1/upstream-oauth-links?filter[user]={}",
394            alice.id
395        ))
396        .bearer(&token)
397        .empty();
398
399        let response = state.request(request).await;
400        response.assert_status(StatusCode::OK);
401        let body: serde_json::Value = response.json();
402        assert_json_snapshot!(body, @r#"
403        {
404          "meta": {
405            "count": 2
406          },
407          "data": [
408            {
409              "type": "upstream-oauth-link",
410              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
411              "attributes": {
412                "created_at": "2022-01-16T14:40:00Z",
413                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
414                "subject": "subject1",
415                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
416                "human_account_name": "alice@acme"
417              },
418              "links": {
419                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
420              },
421              "meta": {
422                "page": {
423                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
424                }
425              }
426            },
427            {
428              "type": "upstream-oauth-link",
429              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
430              "attributes": {
431                "created_at": "2022-01-16T14:40:00Z",
432                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
433                "subject": "subject2",
434                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
435                "human_account_name": "alice@example"
436              },
437              "links": {
438                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
439              },
440              "meta": {
441                "page": {
442                  "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07"
443                }
444              }
445            }
446          ],
447          "links": {
448            "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
449            "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
450            "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
451          }
452        }
453        "#);
454
455        // Filter by provider
456        let request = Request::get(format!(
457            "/api/admin/v1/upstream-oauth-links?filter[provider]={}",
458            provider1.id
459        ))
460        .bearer(&token)
461        .empty();
462
463        let response = state.request(request).await;
464        response.assert_status(StatusCode::OK);
465        let body: serde_json::Value = response.json();
466        assert_json_snapshot!(body, @r#"
467        {
468          "meta": {
469            "count": 2
470          },
471          "data": [
472            {
473              "type": "upstream-oauth-link",
474              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
475              "attributes": {
476                "created_at": "2022-01-16T14:40:00Z",
477                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
478                "subject": "subject1",
479                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
480                "human_account_name": "alice@acme"
481              },
482              "links": {
483                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
484              },
485              "meta": {
486                "page": {
487                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
488                }
489              }
490            },
491            {
492              "type": "upstream-oauth-link",
493              "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4",
494              "attributes": {
495                "created_at": "2022-01-16T14:40:00Z",
496                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
497                "subject": "subject3",
498                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
499                "human_account_name": "bob@acme"
500              },
501              "links": {
502                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4"
503              },
504              "meta": {
505                "page": {
506                  "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4"
507                }
508              }
509            }
510          ],
511          "links": {
512            "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10",
513            "first": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10",
514            "last": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[last]=10"
515          }
516        }
517        "#);
518
519        // Filter by subject
520        let request = Request::get(format!(
521            "/api/admin/v1/upstream-oauth-links?filter[subject]={}",
522            "subject1"
523        ))
524        .bearer(&token)
525        .empty();
526
527        let response = state.request(request).await;
528        response.assert_status(StatusCode::OK);
529        let body: serde_json::Value = response.json();
530        assert_json_snapshot!(body, @r#"
531        {
532          "meta": {
533            "count": 1
534          },
535          "data": [
536            {
537              "type": "upstream-oauth-link",
538              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
539              "attributes": {
540                "created_at": "2022-01-16T14:40:00Z",
541                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
542                "subject": "subject1",
543                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
544                "human_account_name": "alice@acme"
545              },
546              "links": {
547                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
548              },
549              "meta": {
550                "page": {
551                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
552                }
553              }
554            }
555          ],
556          "links": {
557            "self": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10",
558            "first": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10",
559            "last": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[last]=10"
560          }
561        }
562        "#);
563
564        // Test count=false
565        let request = Request::get("/api/admin/v1/upstream-oauth-links?count=false")
566            .bearer(&token)
567            .empty();
568        let response = state.request(request).await;
569        response.assert_status(StatusCode::OK);
570        let body: serde_json::Value = response.json();
571        assert_json_snapshot!(body, @r#"
572        {
573          "data": [
574            {
575              "type": "upstream-oauth-link",
576              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
577              "attributes": {
578                "created_at": "2022-01-16T14:40:00Z",
579                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
580                "subject": "subject1",
581                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
582                "human_account_name": "alice@acme"
583              },
584              "links": {
585                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
586              },
587              "meta": {
588                "page": {
589                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
590                }
591              }
592            },
593            {
594              "type": "upstream-oauth-link",
595              "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4",
596              "attributes": {
597                "created_at": "2022-01-16T14:40:00Z",
598                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
599                "subject": "subject3",
600                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
601                "human_account_name": "bob@acme"
602              },
603              "links": {
604                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4"
605              },
606              "meta": {
607                "page": {
608                  "cursor": "01FSHN9AG0PJZ6DZNTAA1XKPT4"
609                }
610              }
611            },
612            {
613              "type": "upstream-oauth-link",
614              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
615              "attributes": {
616                "created_at": "2022-01-16T14:40:00Z",
617                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
618                "subject": "subject2",
619                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
620                "human_account_name": "alice@example"
621              },
622              "links": {
623                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
624              },
625              "meta": {
626                "page": {
627                  "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07"
628                }
629              }
630            }
631          ],
632          "links": {
633            "self": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10",
634            "first": "/api/admin/v1/upstream-oauth-links?count=false&page[first]=10",
635            "last": "/api/admin/v1/upstream-oauth-links?count=false&page[last]=10"
636          }
637        }
638        "#);
639
640        // Test count=only
641        let request = Request::get("/api/admin/v1/upstream-oauth-links?count=only")
642            .bearer(&token)
643            .empty();
644        let response = state.request(request).await;
645        response.assert_status(StatusCode::OK);
646        let body: serde_json::Value = response.json();
647        assert_json_snapshot!(body, @r###"
648        {
649          "meta": {
650            "count": 3
651          },
652          "links": {
653            "self": "/api/admin/v1/upstream-oauth-links?count=only"
654          }
655        }
656        "###);
657
658        // Test count=false with filtering
659        let request = Request::get(format!(
660            "/api/admin/v1/upstream-oauth-links?count=false&filter[user]={}",
661            alice.id
662        ))
663        .bearer(&token)
664        .empty();
665        let response = state.request(request).await;
666        response.assert_status(StatusCode::OK);
667        let body: serde_json::Value = response.json();
668        assert_json_snapshot!(body, @r#"
669        {
670          "data": [
671            {
672              "type": "upstream-oauth-link",
673              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
674              "attributes": {
675                "created_at": "2022-01-16T14:40:00Z",
676                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
677                "subject": "subject1",
678                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
679                "human_account_name": "alice@acme"
680              },
681              "links": {
682                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
683              },
684              "meta": {
685                "page": {
686                  "cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
687                }
688              }
689            },
690            {
691              "type": "upstream-oauth-link",
692              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
693              "attributes": {
694                "created_at": "2022-01-16T14:40:00Z",
695                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
696                "subject": "subject2",
697                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
698                "human_account_name": "alice@example"
699              },
700              "links": {
701                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
702              },
703              "meta": {
704                "page": {
705                  "cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07"
706                }
707              }
708            }
709          ],
710          "links": {
711            "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
712            "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
713            "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10"
714          }
715        }
716        "#);
717
718        // Test count=only with filtering
719        let request = Request::get(format!(
720            "/api/admin/v1/upstream-oauth-links?count=only&filter[provider]={}",
721            provider1.id
722        ))
723        .bearer(&token)
724        .empty();
725        let response = state.request(request).await;
726        response.assert_status(StatusCode::OK);
727        let body: serde_json::Value = response.json();
728        assert_json_snapshot!(body, @r#"
729        {
730          "meta": {
731            "count": 2
732          },
733          "links": {
734            "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&count=only"
735          }
736        }
737        "#);
738    }
739}