1use 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(¶ms), 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}