Skip to main content

mas_handlers/admin/v1/upstream_oauth_links/
list.rs

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