Skip to main content

rbac_api_client/
client.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use reqwest::Method;
5use reqwest::StatusCode;
6use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
7use rbac_api_contract::{
8    AddMemberRequest, ApiKeyLookupResponse, ApiKeyView, CreateAccountRequest,
9    CreateApiKeyRequest, CreateIdentityRequest, CreateIdentityResponse, CreateResult,
10    CreateRoleRequest, DeleteResult, DeleteUserQuery, DeleteUserResponse, EffectiveRolesQuery,
11    IdentityDocument, ListQuery, LoginRequest, LogoutRequest, MagicRedeemRequest,
12    MagicStartRequest, MagicStartResponse, NonceRedeemRequest, OkResponse,
13    PasswordResetRedeemRequest, PasswordResetStartRequest, RedeemEmailVerificationResponse,
14    RefreshRequest, RegisterRequest, RotateApiKeyResponse, RoleDocument, SetMemberRolesRequest,
15    SetPasswordRequest, SetVerifiedRequest, StartEmailChangeRequest, TokenResponse,
16    UpdateApiKeyRequest, UpdateIdentityRequest, UpdateResult, UserAggregateDetail,
17    UserAggregateListItem, UserListQuery, VerifyEmailQuery, WhoAmIResponse, AccountDocument,
18};
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use serde_json::Value;
22
23use crate::error::{ProblemDetails, RbacApiClientError};
24
25#[derive(Debug, Clone)]
26pub struct RbacApiClientOptions {
27    pub base_url: String,
28    pub authorization: Option<String>,
29    pub headers: Vec<(String, String)>,
30}
31
32#[derive(Debug, Clone, Default)]
33pub struct RequestOptions {
34    pub authorization: Option<String>,
35    pub headers: Vec<(String, String)>,
36}
37
38#[derive(Debug, Clone)]
39pub struct RbacApiClient {
40    base_url: String,
41    authorization: Option<String>,
42    default_headers: HeaderMap,
43    http: reqwest::Client,
44}
45
46impl RbacApiClient {
47    pub fn new(options: RbacApiClientOptions) -> Result<Self, RbacApiClientError> {
48        let base_url = options.base_url.trim_end_matches('/').to_owned();
49        if base_url.is_empty() {
50            return Err(RbacApiClientError::Configuration("base_url is required".to_owned()));
51        }
52
53        let mut default_headers = HeaderMap::new();
54        default_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
55        for (name, value) in options.headers {
56            let header_name = HeaderName::try_from(name.as_str())
57                .map_err(|_| RbacApiClientError::Configuration(format!("invalid header name: {name}")))?;
58            let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| {
59                RbacApiClientError::Configuration(format!("invalid header value for {name}"))
60            })?;
61            default_headers.insert(header_name, header_value);
62        }
63
64        Ok(Self {
65            base_url,
66            authorization: options.authorization,
67            default_headers,
68            http: reqwest::Client::new(),
69        })
70    }
71
72    pub async fn whoami(&self, options: &RequestOptions) -> Result<WhoAmIResponse, RbacApiClientError> {
73        self.request_json(Method::GET, "/auth/whoami", options, Option::<&()>::None, None, true).await
74    }
75
76    pub async fn login(&self, request: &LoginRequest) -> Result<TokenResponse, RbacApiClientError> {
77        self.request_json(Method::POST, "/auth/login", &RequestOptions::default(), Some(request), None, false).await
78    }
79
80    pub async fn register(&self, request: &RegisterRequest, options: &RequestOptions) -> Result<TokenResponse, RbacApiClientError> {
81        self.request_json(Method::POST, "/auth/register", options, Some(request), None, true).await
82    }
83
84    pub async fn start_magic(&self, request: &MagicStartRequest) -> Result<MagicStartResponse, RbacApiClientError> {
85        self.request_json(Method::POST, "/auth/magic/start", &RequestOptions::default(), Some(request), None, false).await
86    }
87
88    pub async fn redeem_magic(&self, request: &MagicRedeemRequest) -> Result<TokenResponse, RbacApiClientError> {
89        self.request_json(Method::POST, "/auth/magic/redeem", &RequestOptions::default(), Some(request), None, false).await
90    }
91
92    pub async fn start_password_reset(&self, request: &PasswordResetStartRequest, options: &RequestOptions) -> Result<MagicStartResponse, RbacApiClientError> {
93        self.request_json(Method::POST, "/auth/password/reset/start", options, Some(request), None, true).await
94    }
95
96    pub async fn redeem_password_reset(&self, request: &PasswordResetRedeemRequest) -> Result<OkResponse, RbacApiClientError> {
97        self.request_json(Method::POST, "/auth/password/reset/redeem", &RequestOptions::default(), Some(request), None, false).await
98    }
99
100    pub async fn set_password(&self, request: &SetPasswordRequest, options: &RequestOptions) -> Result<OkResponse, RbacApiClientError> {
101        self.request_json(Method::POST, "/auth/password", options, Some(request), None, true).await
102    }
103
104    pub async fn abort_email_change(&self, user_id: &str, options: &RequestOptions) -> Result<OkResponse, RbacApiClientError> {
105        self.request_json(Method::POST, &format!("/auth/users/{}/email/abort", encode_segment(user_id)), options, Option::<&()>::None, None, true).await
106    }
107
108    pub async fn start_email_change(&self, user_id: &str, request: &StartEmailChangeRequest, options: &RequestOptions) -> Result<MagicStartResponse, RbacApiClientError> {
109        self.request_json(Method::POST, &format!("/auth/users/{}/email", encode_segment(user_id)), options, Some(request), None, true).await
110    }
111
112    pub async fn verify_email(&self, query: &VerifyEmailQuery) -> Result<RedeemEmailVerificationResponse, RbacApiClientError> {
113        let mut params = Vec::new();
114        if let Some(code) = &query.code {
115            params.push(("code".to_owned(), code.clone()));
116        }
117        if let Some(token) = &query.token {
118            params.push(("token".to_owned(), token.clone()));
119        }
120        self.request_json(Method::GET, "/auth/verify", &RequestOptions::default(), Option::<&()>::None, Some(&params), false).await
121    }
122
123    pub async fn list_users(&self, query: &UserListQuery, options: &RequestOptions) -> Result<Vec<UserAggregateListItem>, RbacApiClientError> {
124        self.request_json(Method::GET, "/auth/users", options, Option::<&()>::None, Some(&user_list_query_pairs(query)), true).await
125    }
126
127    pub async fn get_user(&self, user_id: &str, options: &RequestOptions) -> Result<UserAggregateDetail, RbacApiClientError> {
128        self.request_json(Method::GET, &format!("/auth/users/{}", encode_segment(user_id)), options, Option::<&()>::None, None, true).await
129    }
130
131    pub async fn delete_user(&self, user_id: &str, query: &DeleteUserQuery, options: &RequestOptions) -> Result<DeleteUserResponse, RbacApiClientError> {
132        self.request_json(Method::DELETE, &format!("/auth/users/{}", encode_segment(user_id)), options, Option::<&()>::None, Some(&delete_user_query_pairs(query)), true).await
133    }
134
135    pub async fn refresh(&self, request: &RefreshRequest, options: &RequestOptions) -> Result<TokenResponse, RbacApiClientError> {
136        self.request_json(Method::POST, "/auth/refresh", options, Some(request), None, true).await
137    }
138
139    pub async fn logout(&self, request: &LogoutRequest, options: &RequestOptions) -> Result<OkResponse, RbacApiClientError> {
140        self.request_json(Method::POST, "/auth/logout", options, Some(request), None, true).await
141    }
142
143    pub async fn redeem_nonce(&self, request: &NonceRedeemRequest, options: &RequestOptions) -> Result<Value, RbacApiClientError> {
144        self.request_json(Method::POST, "/auth/nonce/redeem", options, Some(request), None, true).await
145    }
146
147    pub async fn list_accounts(&self, query: &ListQuery, options: &RequestOptions) -> Result<Vec<AccountDocument>, RbacApiClientError> {
148        self.request_json(Method::GET, "/accounts", options, Option::<&()>::None, Some(&list_query_pairs(query)), true).await
149    }
150
151    pub async fn get_account(&self, account_id: &str, options: &RequestOptions) -> Result<AccountDocument, RbacApiClientError> {
152        self.request_json(Method::GET, &format!("/accounts/{}", encode_segment(account_id)), options, Option::<&()>::None, None, true).await
153    }
154
155    pub async fn create_account(&self, request: &CreateAccountRequest, options: &RequestOptions) -> Result<CreateResult, RbacApiClientError> {
156        self.request_json(Method::POST, "/accounts", options, Some(request), None, true).await
157    }
158
159    pub async fn delete_account(&self, account_id: &str, options: &RequestOptions) -> Result<DeleteResult, RbacApiClientError> {
160        self.request_json(Method::DELETE, &format!("/accounts/{}", encode_segment(account_id)), options, Option::<&()>::None, None, true).await
161    }
162
163    pub async fn get_account_meta(&self, account_id: &str, options: &RequestOptions) -> Result<Value, RbacApiClientError> {
164        self.request_json(Method::GET, &format!("/accounts/{}/meta", encode_segment(account_id)), options, Option::<&()>::None, None, true).await
165    }
166
167    pub async fn set_account_meta(&self, account_id: &str, meta: &Value, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
168        self.request_json(Method::PUT, &format!("/accounts/{}/meta", encode_segment(account_id)), options, Some(meta), None, true).await
169    }
170
171    pub async fn patch_account_meta(&self, account_id: &str, meta: &Value, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
172        self.request_json(Method::PATCH, &format!("/accounts/{}/meta", encode_segment(account_id)), options, Some(meta), None, true).await
173    }
174
175    pub async fn add_member(&self, account_id: &str, request: &AddMemberRequest, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
176        self.request_json(Method::POST, &format!("/accounts/{}/members", encode_segment(account_id)), options, Some(request), None, true).await
177    }
178
179    pub async fn set_member_roles(&self, account_id: &str, user_id: &str, request: &SetMemberRolesRequest, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
180        self.request_json(Method::PUT, &format!("/accounts/{}/members/{}", encode_segment(account_id), encode_segment(user_id)), options, Some(request), None, true).await
181    }
182
183    pub async fn remove_member(&self, account_id: &str, user_id: &str, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
184        self.request_json(Method::DELETE, &format!("/accounts/{}/members/{}", encode_segment(account_id), encode_segment(user_id)), options, Option::<&()>::None, None, true).await
185    }
186
187    pub async fn set_license(&self, account_id: &str, key: &str, request: &rbac_api_contract::LicensePayload, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
188        self.request_json(Method::PUT, &format!("/accounts/{}/licenses/{}", encode_segment(account_id), encode_segment(key)), options, Some(request), None, true).await
189    }
190
191    pub async fn remove_license(&self, account_id: &str, key: &str, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
192        self.request_json(Method::DELETE, &format!("/accounts/{}/licenses/{}", encode_segment(account_id), encode_segment(key)), options, Option::<&()>::None, None, true).await
193    }
194
195    pub async fn effective_roles(&self, account_id: &str, query: &EffectiveRolesQuery, options: &RequestOptions) -> Result<Vec<String>, RbacApiClientError> {
196        self.request_json(Method::GET, &format!("/accounts/{}/effective-roles", encode_segment(account_id)), options, Option::<&()>::None, Some(&effective_roles_query_pairs(query)), true).await
197    }
198
199    pub async fn list_identities(&self, query: &ListQuery, options: &RequestOptions) -> Result<Vec<IdentityDocument>, RbacApiClientError> {
200        self.request_json(Method::GET, "/identities", options, Option::<&()>::None, Some(&list_query_pairs(query)), true).await
201    }
202
203    pub async fn get_identity(&self, identity_id: &str, options: &RequestOptions) -> Result<IdentityDocument, RbacApiClientError> {
204        self.request_json(Method::GET, &format!("/identities/{}", encode_segment(identity_id)), options, Option::<&()>::None, None, true).await
205    }
206
207    pub async fn create_identity(&self, request: &CreateIdentityRequest, options: &RequestOptions) -> Result<CreateIdentityResponse, RbacApiClientError> {
208        self.request_json(Method::POST, "/identities", options, Some(request), None, true).await
209    }
210
211    pub async fn update_identity(&self, identity_id: &str, request: &UpdateIdentityRequest, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
212        self.request_json(Method::PUT, &format!("/identities/{}", encode_segment(identity_id)), options, Some(request), None, true).await
213    }
214
215    pub async fn set_verified(&self, identity_id: &str, request: &SetVerifiedRequest, options: &RequestOptions) -> Result<OkResponse, RbacApiClientError> {
216        self.request_json(Method::POST, &format!("/identities/{}/verified", encode_segment(identity_id)), options, Some(request), None, true).await
217    }
218
219    pub async fn delete_identity(&self, identity_id: &str, options: &RequestOptions) -> Result<DeleteResult, RbacApiClientError> {
220        self.request_json(Method::DELETE, &format!("/identities/{}", encode_segment(identity_id)), options, Option::<&()>::None, None, true).await
221    }
222
223    pub async fn list_roles(&self, query: &ListQuery, options: &RequestOptions) -> Result<Vec<RoleDocument>, RbacApiClientError> {
224        self.request_json(Method::GET, "/roles", options, Option::<&()>::None, Some(&list_query_pairs(query)), true).await
225    }
226
227    pub async fn get_role(&self, role_id: &str, options: &RequestOptions) -> Result<RoleDocument, RbacApiClientError> {
228        self.request_json(Method::GET, &format!("/roles/{}", encode_segment(role_id)), options, Option::<&()>::None, None, true).await
229    }
230
231    pub async fn create_role(&self, request: &CreateRoleRequest, options: &RequestOptions) -> Result<CreateResult, RbacApiClientError> {
232        self.request_json(Method::POST, "/roles", options, Some(request), None, true).await
233    }
234
235    pub async fn update_role(&self, role_id: &str, request: &CreateRoleRequest, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
236        self.request_json(Method::PUT, &format!("/roles/{}", encode_segment(role_id)), options, Some(request), None, true).await
237    }
238
239    pub async fn delete_role(&self, role_id: &str, options: &RequestOptions) -> Result<DeleteResult, RbacApiClientError> {
240        self.request_json(Method::DELETE, &format!("/roles/{}", encode_segment(role_id)), options, Option::<&()>::None, None, true).await
241    }
242
243    pub async fn list_api_keys(&self, query: &ListQuery, options: &RequestOptions) -> Result<Vec<ApiKeyView>, RbacApiClientError> {
244        self.request_json(Method::GET, "/apikeys", options, Option::<&()>::None, Some(&list_query_pairs(query)), true).await
245    }
246
247    pub async fn get_api_key(&self, api_key_id: &str, options: &RequestOptions) -> Result<ApiKeyView, RbacApiClientError> {
248        self.request_json(Method::GET, &format!("/apikeys/{}", encode_segment(api_key_id)), options, Option::<&()>::None, None, true).await
249    }
250
251    pub async fn create_api_key(&self, request: &CreateApiKeyRequest, options: &RequestOptions) -> Result<ApiKeyLookupResponse, RbacApiClientError> {
252        self.request_json(Method::POST, "/apikeys", options, Some(request), None, true).await
253    }
254
255    pub async fn update_api_key(&self, api_key_id: &str, request: &UpdateApiKeyRequest, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
256        self.request_json(Method::PUT, &format!("/apikeys/{}", encode_segment(api_key_id)), options, Some(request), None, true).await
257    }
258
259    pub async fn revoke_api_key(&self, api_key_id: &str, options: &RequestOptions) -> Result<UpdateResult, RbacApiClientError> {
260        self.request_json(Method::DELETE, &format!("/apikeys/{}", encode_segment(api_key_id)), options, Option::<&()>::None, None, true).await
261    }
262
263    pub async fn rotate_api_key(&self, api_key_id: &str, options: &RequestOptions) -> Result<RotateApiKeyResponse, RbacApiClientError> {
264        self.request_json(Method::POST, &format!("/apikeys/{}/rotate", encode_segment(api_key_id)), options, Option::<&()>::None, None, true).await
265    }
266
267    async fn request_json<T, B>(
268        &self,
269        method: Method,
270        path: &str,
271        options: &RequestOptions,
272        body: Option<&B>,
273        query: Option<&[(String, String)]>,
274        include_auth: bool,
275    ) -> Result<T, RbacApiClientError>
276    where
277        T: DeserializeOwned,
278        B: Serialize + ?Sized,
279    {
280        let value = self.request_value(method, path, options, body, query, include_auth).await?;
281        serde_json::from_value(value).map_err(|error| RbacApiClientError::Serialization(error.to_string()))
282    }
283
284    async fn request_value<B>(
285        &self,
286        method: Method,
287        path: &str,
288        options: &RequestOptions,
289        body: Option<&B>,
290        query: Option<&[(String, String)]>,
291        include_auth: bool,
292    ) -> Result<Value, RbacApiClientError>
293    where
294        B: Serialize + ?Sized,
295    {
296        let url = self.url(path, query);
297        let headers = self.headers(options, include_auth, body.is_some())?;
298        let mut request = self.http.request(method, url).headers(headers);
299        if let Some(body) = body {
300            let bytes = serde_json::to_vec(body)
301                .map_err(|error| RbacApiClientError::Serialization(error.to_string()))?;
302            request = request.body(bytes);
303        }
304
305        let response = request.send().await?;
306        let status = response.status();
307        if status == StatusCode::NO_CONTENT {
308            return Ok(Value::Null);
309        }
310        let content_type = response
311            .headers()
312            .get(reqwest::header::CONTENT_TYPE)
313            .and_then(|value| value.to_str().ok())
314            .unwrap_or_default()
315            .to_owned();
316        let text = response.text().await?;
317        let payload = if content_type.contains("application/json") || content_type.contains("application/problem+json") {
318            serde_json::from_str::<Value>(&text)
319                .map_err(|error| RbacApiClientError::Serialization(error.to_string()))?
320        } else {
321            Value::String(text)
322        };
323
324        if status.is_success() {
325            return Ok(payload);
326        }
327
328        if let Ok(problem) = serde_json::from_value::<ProblemDetails>(payload.clone()) {
329            return Err(RbacApiClientError::Problem(problem));
330        }
331
332        Err(RbacApiClientError::Transport(match payload {
333            Value::String(value) if !value.is_empty() => value,
334            _ => format!("request failed: {status}"),
335        }))
336    }
337
338    fn url(&self, path: &str, query: Option<&[(String, String)]>) -> String {
339        let mut url = format!("{}{}", self.base_url, path);
340        if let Some(query) = query {
341            if !query.is_empty() {
342                let mut first = true;
343                for (key, value) in query {
344                    url.push(if first { '?' } else { '&' });
345                    first = false;
346                    url.push_str(&urlencoding::encode(key));
347                    url.push('=');
348                    url.push_str(&urlencoding::encode(value));
349                }
350            }
351        }
352        url
353    }
354
355    fn headers(&self, options: &RequestOptions, include_auth: bool, include_content_type: bool) -> Result<HeaderMap, RbacApiClientError> {
356        let mut headers = self.default_headers.clone();
357        for (name, value) in &options.headers {
358            let header_name = HeaderName::try_from(name.as_str())
359                .map_err(|_| RbacApiClientError::Configuration(format!("invalid header name: {name}")))?;
360            let header_value = HeaderValue::try_from(value.as_str())
361                .map_err(|_| RbacApiClientError::Configuration(format!("invalid header value for {name}")))?;
362            headers.insert(header_name, header_value);
363        }
364        if include_auth {
365            if let Some(authorization) = options.authorization.as_ref().or(self.authorization.as_ref()) {
366                headers.insert(
367                    AUTHORIZATION,
368                    HeaderValue::try_from(authorization.as_str())
369                        .map_err(|_| RbacApiClientError::Configuration("invalid authorization header value".to_owned()))?,
370                );
371            }
372        }
373        if include_content_type {
374            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
375        }
376        Ok(headers)
377    }
378}
379
380fn encode_segment(value: &str) -> String {
381    urlencoding::encode(value).into_owned()
382}
383
384fn list_query_pairs(query: &ListQuery) -> Vec<(String, String)> {
385    let mut pairs = Vec::new();
386    if let Some(skip) = query.skip {
387        pairs.push(("skip".to_owned(), skip.to_string()));
388    }
389    if let Some(limit) = query.limit {
390        pairs.push(("limit".to_owned(), limit.to_string()));
391    }
392    pairs
393}
394
395fn user_list_query_pairs(query: &UserListQuery) -> Vec<(String, String)> {
396    let mut pairs = list_query_pairs(&ListQuery {
397        skip: query.skip,
398        limit: query.limit,
399    });
400    if let Some(q) = &query.q {
401        pairs.push(("q".to_owned(), q.clone()));
402    }
403    pairs
404}
405
406fn delete_user_query_pairs(query: &DeleteUserQuery) -> Vec<(String, String)> {
407    let mut pairs = Vec::new();
408    if let Some(hard) = query.hard {
409        pairs.push(("hard".to_owned(), hard.to_string()));
410    }
411    pairs
412}
413
414fn effective_roles_query_pairs(query: &EffectiveRolesQuery) -> Vec<(String, String)> {
415    let mut pairs = vec![("userId".to_owned(), query.user_id.clone())];
416    if let Some(at) = query.at {
417        pairs.push(("at".to_owned(), at.to_string()));
418    }
419    pairs
420}
421
422#[cfg(test)]
423mod tests {
424    use std::collections::HashMap;
425
426    use axum::Json;
427    use axum::Router;
428    use axum::extract::Query;
429    use axum::http::HeaderMap;
430    use axum::routing::{get, post};
431    use serde_json::json;
432
433    use super::*;
434
435    fn client(base_url: String) -> RbacApiClient {
436        RbacApiClient::new(RbacApiClientOptions {
437            base_url,
438            authorization: Some("Bearer default-token".to_owned()),
439            headers: vec![("x-client".to_owned(), "rbac-test".to_owned())],
440        })
441        .expect("client")
442    }
443
444    #[tokio::test]
445    async fn login_posts_without_default_auth_requirement() {
446        let app = Router::new().route(
447            "/auth/login",
448            post(|headers: HeaderMap, Json(payload): Json<LoginRequest>| async move {
449                let auth = headers.get(AUTHORIZATION).and_then(|value| value.to_str().ok());
450                Json(json!({"auth": auth, "email": payload.email}))
451            }),
452        );
453        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
454        let address = listener.local_addr().expect("addr");
455        tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); });
456
457        let value: Value = client(format!("http://{address}"))
458            .request_json(
459                Method::POST,
460                "/auth/login",
461                &RequestOptions::default(),
462                Some(&LoginRequest { email: "user@example.com".to_owned(), password: "secret".to_owned() }),
463                None,
464                false,
465            )
466            .await
467            .expect("login");
468
469        assert_eq!(value, json!({"auth": null, "email": "user@example.com"}));
470    }
471
472    #[tokio::test]
473    async fn whoami_sends_default_authorization_header() {
474        let app = Router::new().route(
475            "/auth/whoami",
476            get(|headers: HeaderMap| async move {
477                let auth = headers
478                    .get(AUTHORIZATION)
479                    .and_then(|value| value.to_str().ok())
480                    .unwrap_or_default()
481                    .to_owned();
482                Json(WhoAmIResponse { account: auth, permissions: Vec::new() })
483            }),
484        );
485        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
486        let address = listener.local_addr().expect("addr");
487        tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); });
488
489        let result = client(format!("http://{address}"))
490            .whoami(&RequestOptions::default())
491            .await
492            .expect("whoami");
493
494        assert_eq!(result.account, "Bearer default-token");
495    }
496
497    #[tokio::test]
498    async fn effective_roles_encodes_query_fields() {
499        let app = Router::new().route(
500            "/accounts/demo/effective-roles",
501            get(|Query(query): Query<HashMap<String, String>>| async move {
502                Json(vec![
503                    query.get("userId").cloned().unwrap_or_default(),
504                    query.get("at").cloned().unwrap_or_default(),
505                ])
506            }),
507        );
508        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
509        let address = listener.local_addr().expect("addr");
510        tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); });
511
512        let result = client(format!("http://{address}"))
513            .effective_roles(
514                "demo",
515                &EffectiveRolesQuery { user_id: "user_1".to_owned(), at: Some(123) },
516                &RequestOptions::default(),
517            )
518            .await
519            .expect("effective roles");
520
521        assert_eq!(result, vec!["user_1".to_owned(), "123".to_owned()]);
522    }
523
524    #[tokio::test]
525    async fn verify_email_uses_public_query_route() {
526        let app = Router::new().route(
527            "/auth/verify",
528            get(|Query(query): Query<HashMap<String, String>>| async move {
529                Json(RedeemEmailVerificationResponse {
530                    ok: true,
531                    verified: query.contains_key("code") || query.contains_key("token"),
532                    committed_pending: None,
533                    require_password: None,
534                })
535            }),
536        );
537        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("listener");
538        let address = listener.local_addr().expect("addr");
539        tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); });
540
541        let response = client(format!("http://{address}"))
542            .verify_email(&VerifyEmailQuery { code: Some("abc".to_owned()), token: None })
543            .await
544            .expect("verify");
545
546        assert!(response.verified);
547    }
548}