1use 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 #[serde(rename = "filter[user]")]
34 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
35 user: Option<Ulid>,
36
37 #[serde(rename = "filter[provider]")]
39 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
40 provider: Option<Ulid>,
41
42 #[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 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 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) = ¶ms.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 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 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 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 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 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 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 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 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 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}