1use crate::errors::{AuthError, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22
23pub mod schema {
27 pub const USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
28 pub const GROUP: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
29 pub const ENTERPRISE_USER: &str = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
30 pub const LIST_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
31 pub const PATCH_OP: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
32 pub const BULK_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:BulkRequest";
33 pub const BULK_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
34 pub const ERROR: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
35 pub const SEARCH_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:SearchRequest";
36 pub const SERVICE_PROVIDER_CONFIG: &str =
37 "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Meta {
44 pub resource_type: String,
45 pub created: Option<String>,
46 pub last_modified: Option<String>,
47 pub location: Option<String>,
48 pub version: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct MultiValuedAttr {
54 pub value: String,
55 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
56 pub attr_type: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub primary: Option<bool>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub display: Option<String>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Name {
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub formatted: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub family_name: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub given_name: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub middle_name: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub honorific_prefix: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub honorific_suffix: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct ScimUser {
85 pub schemas: Vec<String>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub id: Option<String>,
91
92 pub user_name: String,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub name: Option<Name>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub display_name: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub emails: Option<Vec<MultiValuedAttr>>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub phone_numbers: Option<Vec<MultiValuedAttr>>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub active: Option<bool>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub meta: Option<Meta>,
118
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub groups: Option<Vec<MultiValuedAttr>>,
122
123 #[serde(flatten)]
125 pub extensions: HashMap<String, serde_json::Value>,
126}
127
128impl ScimUser {
129 pub fn new(user_name: impl Into<String>) -> Self {
131 Self {
132 schemas: vec![schema::USER.to_string()],
133 id: None,
134 user_name: user_name.into(),
135 name: None,
136 display_name: None,
137 emails: None,
138 phone_numbers: None,
139 active: Some(true),
140 meta: None,
141 groups: None,
142 extensions: HashMap::new(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct ScimGroup {
151 pub schemas: Vec<String>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub id: Option<String>,
155
156 pub display_name: String,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub members: Option<Vec<MultiValuedAttr>>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub meta: Option<Meta>,
163}
164
165impl ScimGroup {
166 pub fn new(display_name: impl Into<String>) -> Self {
167 Self {
168 schemas: vec![schema::GROUP.to_string()],
169 id: None,
170 display_name: display_name.into(),
171 members: None,
172 meta: None,
173 }
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ListResponse<T> {
183 pub schemas: Vec<String>,
184 pub total_results: u64,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub start_index: Option<u64>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub items_per_page: Option<u64>,
189 #[serde(rename = "Resources")]
190 pub resources: Vec<T>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PatchOp {
196 pub schemas: Vec<String>,
197 #[serde(rename = "Operations")]
198 pub operations: Vec<PatchOperation>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PatchOperation {
204 pub op: PatchOpType,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub path: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub value: Option<serde_json::Value>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(rename_all = "lowercase")]
214pub enum PatchOpType {
215 Add,
216 Remove,
217 Replace,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct BulkRequest {
224 pub schemas: Vec<String>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub fail_on_errors: Option<u32>,
227 #[serde(rename = "Operations")]
228 pub operations: Vec<BulkOperation>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct BulkResponse {
235 pub schemas: Vec<String>,
236 #[serde(rename = "Operations")]
237 pub operations: Vec<BulkOperationResponse>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct BulkOperation {
244 pub method: BulkMethod,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub bulk_id: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub path: Option<String>,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub data: Option<serde_json::Value>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct BulkOperationResponse {
257 pub method: BulkMethod,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub bulk_id: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub location: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub status: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub response: Option<serde_json::Value>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "UPPERCASE")]
270pub enum BulkMethod {
271 Post,
272 Put,
273 Patch,
274 Delete,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct ScimError {
281 pub schemas: Vec<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 pub status: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub scim_type: Option<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub detail: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct SearchRequest {
294 pub schemas: Vec<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub filter: Option<String>,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub sort_by: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub sort_order: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub start_index: Option<u64>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub count: Option<u64>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub attributes: Option<Vec<String>>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub excluded_attributes: Option<Vec<String>>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct ServiceProviderConfig {
315 pub schemas: Vec<String>,
316 pub patch: Supported,
317 pub bulk: BulkSupported,
318 pub filter: FilterSupported,
319 pub change_password: Supported,
320 pub sort: Supported,
321 pub etag: Supported,
322 pub authentication_schemes: Vec<AuthenticationScheme>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct Supported {
327 pub supported: bool,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct BulkSupported {
333 pub supported: bool,
334 pub max_operations: u32,
335 pub max_payload_size: u64,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct FilterSupported {
341 pub supported: bool,
342 pub max_results: u32,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct AuthenticationScheme {
348 pub name: String,
349 pub description: String,
350 #[serde(rename = "type")]
351 pub scheme_type: String,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub spec_uri: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub documentation_uri: Option<String>,
356 pub primary: bool,
357}
358
359#[derive(Debug, Clone)]
363pub struct ScimClientConfig {
364 pub base_url: String,
366
367 pub bearer_token: String,
369
370 pub timeout_secs: u64,
372}
373
374pub struct ScimClient {
376 config: ScimClientConfig,
377 http: reqwest::Client,
378}
379
380impl ScimClient {
381 pub fn new(config: ScimClientConfig) -> Result<Self> {
383 if !config.base_url.starts_with("https://") {
384 return Err(AuthError::config(
385 "SCIM base URL must use HTTPS for security",
386 ));
387 }
388
389 let http = reqwest::Client::builder()
390 .timeout(std::time::Duration::from_secs(config.timeout_secs))
391 .build()
392 .map_err(|e| AuthError::internal(format!("Failed to build HTTP client: {e}")))?;
393
394 Ok(Self { config, http })
395 }
396
397 pub async fn create_user(&self, user: &ScimUser) -> Result<ScimUser> {
401 let url = format!("{}/Users", self.config.base_url);
402 let resp = self
403 .http
404 .post(&url)
405 .bearer_auth(&self.config.bearer_token)
406 .json(user)
407 .send()
408 .await
409 .map_err(|e| AuthError::internal(format!("SCIM create user request failed: {e}")))?;
410
411 if !resp.status().is_success() {
412 let status = resp.status();
413 let body = resp.text().await.unwrap_or_default();
414 return Err(AuthError::internal(format!(
415 "SCIM create user failed (HTTP {status}): {body}"
416 )));
417 }
418
419 resp.json::<ScimUser>()
420 .await
421 .map_err(|e| AuthError::internal(format!("SCIM create user parse error: {e}")))
422 }
423
424 pub async fn get_user(&self, id: &str) -> Result<ScimUser> {
426 let url = format!("{}/Users/{}", self.config.base_url, id);
427 let resp = self
428 .http
429 .get(&url)
430 .bearer_auth(&self.config.bearer_token)
431 .send()
432 .await
433 .map_err(|e| AuthError::internal(format!("SCIM get user request failed: {e}")))?;
434
435 if !resp.status().is_success() {
436 let status = resp.status();
437 let body = resp.text().await.unwrap_or_default();
438 return Err(AuthError::internal(format!(
439 "SCIM get user failed (HTTP {status}): {body}"
440 )));
441 }
442
443 resp.json::<ScimUser>()
444 .await
445 .map_err(|e| AuthError::internal(format!("SCIM get user parse error: {e}")))
446 }
447
448 pub async fn replace_user(&self, id: &str, user: &ScimUser) -> Result<ScimUser> {
450 let url = format!("{}/Users/{}", self.config.base_url, id);
451 let resp = self
452 .http
453 .put(&url)
454 .bearer_auth(&self.config.bearer_token)
455 .json(user)
456 .send()
457 .await
458 .map_err(|e| AuthError::internal(format!("SCIM replace user request failed: {e}")))?;
459
460 if !resp.status().is_success() {
461 let status = resp.status();
462 let body = resp.text().await.unwrap_or_default();
463 return Err(AuthError::internal(format!(
464 "SCIM replace user failed (HTTP {status}): {body}"
465 )));
466 }
467
468 resp.json::<ScimUser>()
469 .await
470 .map_err(|e| AuthError::internal(format!("SCIM replace user parse error: {e}")))
471 }
472
473 pub async fn patch_user(&self, id: &str, patch: &PatchOp) -> Result<ScimUser> {
475 let url = format!("{}/Users/{}", self.config.base_url, id);
476 let resp = self
477 .http
478 .patch(&url)
479 .bearer_auth(&self.config.bearer_token)
480 .json(patch)
481 .send()
482 .await
483 .map_err(|e| AuthError::internal(format!("SCIM patch user request failed: {e}")))?;
484
485 if !resp.status().is_success() {
486 let status = resp.status();
487 let body = resp.text().await.unwrap_or_default();
488 return Err(AuthError::internal(format!(
489 "SCIM patch user failed (HTTP {status}): {body}"
490 )));
491 }
492
493 resp.json::<ScimUser>()
494 .await
495 .map_err(|e| AuthError::internal(format!("SCIM patch user parse error: {e}")))
496 }
497
498 pub async fn delete_user(&self, id: &str) -> Result<()> {
500 let url = format!("{}/Users/{}", self.config.base_url, id);
501 let resp = self
502 .http
503 .delete(&url)
504 .bearer_auth(&self.config.bearer_token)
505 .send()
506 .await
507 .map_err(|e| AuthError::internal(format!("SCIM delete user request failed: {e}")))?;
508
509 if !resp.status().is_success() {
510 let status = resp.status();
511 let body = resp.text().await.unwrap_or_default();
512 return Err(AuthError::internal(format!(
513 "SCIM delete user failed (HTTP {status}): {body}"
514 )));
515 }
516
517 Ok(())
518 }
519
520 pub async fn list_users(
522 &self,
523 filter: Option<&str>,
524 start_index: Option<u64>,
525 count: Option<u64>,
526 ) -> Result<ListResponse<ScimUser>> {
527 let mut url = format!("{}/Users", self.config.base_url);
528 let mut params = Vec::new();
529 if let Some(f) = filter {
530 params.push(format!("filter={}", urlencoding::encode(f)));
531 }
532 if let Some(si) = start_index {
533 params.push(format!("startIndex={si}"));
534 }
535 if let Some(c) = count {
536 params.push(format!("count={c}"));
537 }
538 if !params.is_empty() {
539 url = format!("{}?{}", url, params.join("&"));
540 }
541
542 let resp = self
543 .http
544 .get(&url)
545 .bearer_auth(&self.config.bearer_token)
546 .send()
547 .await
548 .map_err(|e| AuthError::internal(format!("SCIM list users request failed: {e}")))?;
549
550 if !resp.status().is_success() {
551 let status = resp.status();
552 let body = resp.text().await.unwrap_or_default();
553 return Err(AuthError::internal(format!(
554 "SCIM list users failed (HTTP {status}): {body}"
555 )));
556 }
557
558 resp.json::<ListResponse<ScimUser>>()
559 .await
560 .map_err(|e| AuthError::internal(format!("SCIM list users parse error: {e}")))
561 }
562
563 pub async fn create_group(&self, group: &ScimGroup) -> Result<ScimGroup> {
567 let url = format!("{}/Groups", self.config.base_url);
568 let resp = self
569 .http
570 .post(&url)
571 .bearer_auth(&self.config.bearer_token)
572 .json(group)
573 .send()
574 .await
575 .map_err(|e| AuthError::internal(format!("SCIM create group request failed: {e}")))?;
576
577 if !resp.status().is_success() {
578 let status = resp.status();
579 let body = resp.text().await.unwrap_or_default();
580 return Err(AuthError::internal(format!(
581 "SCIM create group failed (HTTP {status}): {body}"
582 )));
583 }
584
585 resp.json::<ScimGroup>()
586 .await
587 .map_err(|e| AuthError::internal(format!("SCIM create group parse error: {e}")))
588 }
589
590 pub async fn get_group(&self, id: &str) -> Result<ScimGroup> {
592 let url = format!("{}/Groups/{}", self.config.base_url, id);
593 let resp = self
594 .http
595 .get(&url)
596 .bearer_auth(&self.config.bearer_token)
597 .send()
598 .await
599 .map_err(|e| AuthError::internal(format!("SCIM get group request failed: {e}")))?;
600
601 if !resp.status().is_success() {
602 let status = resp.status();
603 let body = resp.text().await.unwrap_or_default();
604 return Err(AuthError::internal(format!(
605 "SCIM get group failed (HTTP {status}): {body}"
606 )));
607 }
608
609 resp.json::<ScimGroup>()
610 .await
611 .map_err(|e| AuthError::internal(format!("SCIM get group parse error: {e}")))
612 }
613
614 pub async fn delete_group(&self, id: &str) -> Result<()> {
616 let url = format!("{}/Groups/{}", self.config.base_url, id);
617 let resp = self
618 .http
619 .delete(&url)
620 .bearer_auth(&self.config.bearer_token)
621 .send()
622 .await
623 .map_err(|e| AuthError::internal(format!("SCIM delete group request failed: {e}")))?;
624
625 if !resp.status().is_success() {
626 let status = resp.status();
627 let body = resp.text().await.unwrap_or_default();
628 return Err(AuthError::internal(format!(
629 "SCIM delete group failed (HTTP {status}): {body}"
630 )));
631 }
632
633 Ok(())
634 }
635
636 pub async fn list_groups(
638 &self,
639 filter: Option<&str>,
640 start_index: Option<u64>,
641 count: Option<u64>,
642 ) -> Result<ListResponse<ScimGroup>> {
643 let mut url = format!("{}/Groups", self.config.base_url);
644 let mut params = Vec::new();
645 if let Some(f) = filter {
646 params.push(format!("filter={}", urlencoding::encode(f)));
647 }
648 if let Some(si) = start_index {
649 params.push(format!("startIndex={si}"));
650 }
651 if let Some(c) = count {
652 params.push(format!("count={c}"));
653 }
654 if !params.is_empty() {
655 url = format!("{}?{}", url, params.join("&"));
656 }
657
658 let resp = self
659 .http
660 .get(&url)
661 .bearer_auth(&self.config.bearer_token)
662 .send()
663 .await
664 .map_err(|e| AuthError::internal(format!("SCIM list groups request failed: {e}")))?;
665
666 if !resp.status().is_success() {
667 let status = resp.status();
668 let body = resp.text().await.unwrap_or_default();
669 return Err(AuthError::internal(format!(
670 "SCIM list groups failed (HTTP {status}): {body}"
671 )));
672 }
673
674 resp.json::<ListResponse<ScimGroup>>()
675 .await
676 .map_err(|e| AuthError::internal(format!("SCIM list groups parse error: {e}")))
677 }
678
679 pub async fn bulk(&self, request: &BulkRequest) -> Result<BulkResponse> {
683 let url = format!("{}/Bulk", self.config.base_url);
684 let resp = self
685 .http
686 .post(&url)
687 .bearer_auth(&self.config.bearer_token)
688 .json(request)
689 .send()
690 .await
691 .map_err(|e| AuthError::internal(format!("SCIM bulk request failed: {e}")))?;
692
693 if !resp.status().is_success() {
694 let status = resp.status();
695 let body = resp.text().await.unwrap_or_default();
696 return Err(AuthError::internal(format!(
697 "SCIM bulk request failed (HTTP {status}): {body}"
698 )));
699 }
700
701 resp.json::<BulkResponse>()
702 .await
703 .map_err(|e| AuthError::internal(format!("SCIM bulk response parse error: {e}")))
704 }
705
706 pub async fn get_service_provider_config(&self) -> Result<ServiceProviderConfig> {
710 let url = format!("{}/ServiceProviderConfig", self.config.base_url);
711 let resp = self
712 .http
713 .get(&url)
714 .bearer_auth(&self.config.bearer_token)
715 .send()
716 .await
717 .map_err(|e| {
718 AuthError::internal(format!("SCIM service provider config request failed: {e}"))
719 })?;
720
721 if !resp.status().is_success() {
722 let status = resp.status();
723 let body = resp.text().await.unwrap_or_default();
724 return Err(AuthError::internal(format!(
725 "SCIM service provider config failed (HTTP {status}): {body}"
726 )));
727 }
728
729 resp.json::<ServiceProviderConfig>().await.map_err(|e| {
730 AuthError::internal(format!("SCIM service provider config parse error: {e}"))
731 })
732 }
733}
734
735impl PatchOp {
738 pub fn new(operations: Vec<PatchOperation>) -> Self {
740 Self {
741 schemas: vec![schema::PATCH_OP.to_string()],
742 operations,
743 }
744 }
745}
746
747impl SearchRequest {
748 pub fn with_filter(filter: impl Into<String>) -> Self {
750 Self {
751 schemas: vec![schema::SEARCH_REQUEST.to_string()],
752 filter: Some(filter.into()),
753 sort_by: None,
754 sort_order: None,
755 start_index: None,
756 count: None,
757 attributes: None,
758 excluded_attributes: None,
759 }
760 }
761}
762
763impl BulkRequest {
764 pub fn new(operations: Vec<BulkOperation>, fail_on_errors: Option<u32>) -> Self {
766 Self {
767 schemas: vec![schema::BULK_REQUEST.to_string()],
768 fail_on_errors,
769 operations,
770 }
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[test]
779 fn test_scim_user_serialization() {
780 let mut user = ScimUser::new("jdoe");
781 user.name = Some(Name {
782 given_name: Some("John".into()),
783 family_name: Some("Doe".into()),
784 ..Default::default()
785 });
786 user.emails = Some(vec![MultiValuedAttr {
787 value: "jdoe@example.com".into(),
788 attr_type: Some("work".into()),
789 primary: Some(true),
790 display: None,
791 }]);
792
793 let json = serde_json::to_string(&user).expect("serialize");
794 assert!(json.contains("\"userName\":\"jdoe\""));
795 assert!(json.contains(schema::USER));
796 }
797
798 #[test]
799 fn test_scim_group_serialization() {
800 let group = ScimGroup::new("Engineering");
801 let json = serde_json::to_string(&group).expect("serialize");
802 assert!(json.contains("\"displayName\":\"Engineering\""));
803 assert!(json.contains(schema::GROUP));
804 }
805
806 #[test]
807 fn test_patch_op_construction() {
808 let patch = PatchOp::new(vec![PatchOperation {
809 op: PatchOpType::Replace,
810 path: Some("active".into()),
811 value: Some(serde_json::Value::Bool(false)),
812 }]);
813 assert_eq!(patch.schemas[0], schema::PATCH_OP);
814 assert_eq!(patch.operations.len(), 1);
815 }
816
817 #[test]
818 fn test_scim_user_roundtrip() {
819 let user = ScimUser::new("alice");
820 let json = serde_json::to_value(&user).expect("to value");
821 let parsed: ScimUser = serde_json::from_value(json).expect("from value");
822 assert_eq!(parsed.user_name, "alice");
823 assert_eq!(parsed.active, Some(true));
824 }
825
826 #[test]
827 fn test_bulk_request_construction() {
828 let bulk = BulkRequest::new(
829 vec![BulkOperation {
830 method: BulkMethod::Post,
831 bulk_id: Some("op1".into()),
832 path: Some("/Users".into()),
833 data: Some(serde_json::to_value(ScimUser::new("bulk_user")).expect("val")),
834 }],
835 Some(1),
836 );
837 assert_eq!(bulk.schemas[0], schema::BULK_REQUEST);
838 assert_eq!(bulk.fail_on_errors, Some(1));
839 }
840
841 #[test]
842 fn test_scim_user_empty_username() {
843 let user = ScimUser::new("");
844 assert_eq!(user.user_name, "");
845 let json = serde_json::to_value(&user).unwrap();
847 let parsed: ScimUser = serde_json::from_value(json).unwrap();
848 assert_eq!(parsed.user_name, "");
849 }
850
851 #[test]
852 fn test_scim_user_all_optional_fields() {
853 let mut user = ScimUser::new("fulluser");
854 user.id = Some("u-123".into());
855 user.display_name = Some("Full User".into());
856 user.name = Some(Name {
857 formatted: Some("Dr. Full A. User Jr.".into()),
858 family_name: Some("User".into()),
859 given_name: Some("Full".into()),
860 middle_name: Some("A".into()),
861 honorific_prefix: Some("Dr.".into()),
862 honorific_suffix: Some("Jr.".into()),
863 });
864 user.emails = Some(vec![
865 MultiValuedAttr {
866 value: "work@example.com".into(),
867 attr_type: Some("work".into()),
868 primary: Some(true),
869 display: Some("Work Email".into()),
870 },
871 MultiValuedAttr {
872 value: "home@example.com".into(),
873 attr_type: Some("home".into()),
874 primary: Some(false),
875 display: None,
876 },
877 ]);
878 user.phone_numbers = Some(vec![MultiValuedAttr {
879 value: "+1-555-0100".into(),
880 attr_type: Some("mobile".into()),
881 primary: Some(true),
882 display: None,
883 }]);
884 user.active = Some(false);
885 user.groups = Some(vec![MultiValuedAttr {
886 value: "g-eng".into(),
887 attr_type: None,
888 primary: None,
889 display: Some("Engineering".into()),
890 }]);
891
892 let json = serde_json::to_string(&user).unwrap();
893 let parsed: ScimUser =
894 serde_json::from_value(serde_json::from_str(&json).unwrap()).unwrap();
895 assert_eq!(parsed.id.as_deref(), Some("u-123"));
896 assert_eq!(parsed.display_name.as_deref(), Some("Full User"));
897 assert_eq!(parsed.active, Some(false));
898 assert_eq!(parsed.emails.as_ref().unwrap().len(), 2);
899 assert_eq!(parsed.phone_numbers.as_ref().unwrap().len(), 1);
900 assert_eq!(parsed.groups.as_ref().unwrap().len(), 1);
901 let name = parsed.name.as_ref().unwrap();
902 assert_eq!(name.honorific_prefix.as_deref(), Some("Dr."));
903 assert_eq!(name.honorific_suffix.as_deref(), Some("Jr."));
904 }
905
906 #[test]
907 fn test_scim_user_deserialization_from_json() {
908 let json_str = r#"{
909 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
910 "id": "server-1",
911 "userName": "jdoe",
912 "displayName": "Jane Doe",
913 "active": true,
914 "meta": {
915 "resourceType": "User",
916 "created": "2024-01-01T00:00:00Z",
917 "lastModified": "2024-06-01T00:00:00Z",
918 "location": "https://scim.example.com/Users/server-1",
919 "version": "W/\"1\""
920 }
921 }"#;
922
923 let user: ScimUser = serde_json::from_str(json_str).unwrap();
924 assert_eq!(user.user_name, "jdoe");
925 assert_eq!(user.id.as_deref(), Some("server-1"));
926 assert_eq!(user.display_name.as_deref(), Some("Jane Doe"));
927 let meta = user.meta.as_ref().unwrap();
928 assert_eq!(meta.resource_type, "User");
929 assert_eq!(meta.version.as_deref(), Some("W/\"1\""));
930 }
931
932 #[test]
933 fn test_scim_group_with_members() {
934 let mut group = ScimGroup::new("DevOps");
935 group.members = Some(vec![
936 MultiValuedAttr {
937 value: "u-1".into(),
938 attr_type: Some("User".into()),
939 primary: None,
940 display: Some("Alice".into()),
941 },
942 MultiValuedAttr {
943 value: "u-2".into(),
944 attr_type: Some("User".into()),
945 primary: None,
946 display: Some("Bob".into()),
947 },
948 ]);
949
950 let json = serde_json::to_string(&group).unwrap();
951 let parsed: ScimGroup = serde_json::from_str(&json).unwrap();
952 assert_eq!(parsed.display_name, "DevOps");
953 let members = parsed.members.unwrap();
954 assert_eq!(members.len(), 2);
955 assert_eq!(members[0].display.as_deref(), Some("Alice"));
956 }
957
958 #[test]
959 fn test_patch_op_add_and_remove() {
960 let patch = PatchOp::new(vec![
961 PatchOperation {
962 op: PatchOpType::Add,
963 path: Some("emails".into()),
964 value: Some(serde_json::json!([{"value": "new@example.com", "type": "work"}])),
965 },
966 PatchOperation {
967 op: PatchOpType::Remove,
968 path: Some("phoneNumbers".into()),
969 value: None,
970 },
971 ]);
972
973 assert_eq!(patch.operations.len(), 2);
974
975 let json = serde_json::to_string(&patch).unwrap();
977 assert!(json.contains("\"add\""));
978 assert!(json.contains("\"remove\""));
979 assert!(json.contains("new@example.com"));
980 }
981
982 #[test]
983 fn test_bulk_request_multiple_operations() {
984 let bulk = BulkRequest::new(
985 vec![
986 BulkOperation {
987 method: BulkMethod::Post,
988 bulk_id: Some("create-1".into()),
989 path: Some("/Users".into()),
990 data: Some(serde_json::to_value(ScimUser::new("user1")).unwrap()),
991 },
992 BulkOperation {
993 method: BulkMethod::Put,
994 bulk_id: Some("update-1".into()),
995 path: Some("/Users/existing-id".into()),
996 data: Some(serde_json::to_value(ScimUser::new("user1_updated")).unwrap()),
997 },
998 BulkOperation {
999 method: BulkMethod::Delete,
1000 bulk_id: Some("delete-1".into()),
1001 path: Some("/Users/old-id".into()),
1002 data: None,
1003 },
1004 ],
1005 Some(2),
1006 );
1007
1008 assert_eq!(bulk.operations.len(), 3);
1009 assert_eq!(bulk.fail_on_errors, Some(2));
1010
1011 let json = serde_json::to_string(&bulk).unwrap();
1013 let parsed: BulkRequest = serde_json::from_str(&json).unwrap();
1014 assert_eq!(parsed.operations.len(), 3);
1015 }
1016
1017 #[test]
1018 fn test_search_request_with_filter() {
1019 let search = SearchRequest::with_filter("userName eq \"jdoe\"");
1020 assert_eq!(search.filter.as_deref(), Some("userName eq \"jdoe\""));
1021 assert_eq!(search.schemas[0], schema::SEARCH_REQUEST);
1022 assert!(search.sort_by.is_none());
1023 assert!(search.count.is_none());
1024 }
1025
1026 #[test]
1027 fn test_scim_error_serialization() {
1028 let error = ScimError {
1029 schemas: vec![schema::ERROR.to_string()],
1030 status: Some("400".into()),
1031 scim_type: Some("invalidFilter".into()),
1032 detail: Some("The filter syntax is invalid".into()),
1033 };
1034
1035 let json = serde_json::to_string(&error).unwrap();
1036 let parsed: ScimError = serde_json::from_str(&json).unwrap();
1037 assert_eq!(parsed.status.as_deref(), Some("400"));
1038 assert_eq!(parsed.scim_type.as_deref(), Some("invalidFilter"));
1039 }
1040
1041 #[test]
1042 fn test_scim_user_active_defaults_to_true() {
1043 let user = ScimUser::new("defaultuser");
1044 assert_eq!(user.active, Some(true));
1045 }
1046
1047 #[test]
1048 fn test_scim_user_extensions_roundtrip() {
1049 let mut user = ScimUser::new("extuser");
1050 user.extensions.insert(
1051 "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User".to_string(),
1052 serde_json::json!({
1053 "employeeNumber": "12345",
1054 "department": "Engineering"
1055 }),
1056 );
1057
1058 let json = serde_json::to_string(&user).unwrap();
1059 let parsed: ScimUser = serde_json::from_str(&json).unwrap();
1060 let ext = &parsed.extensions["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"];
1061 assert_eq!(ext["employeeNumber"], "12345");
1062 assert_eq!(ext["department"], "Engineering");
1063 }
1064
1065 #[test]
1066 fn test_scim_client_rejects_http_url() {
1067 let config = ScimClientConfig {
1068 base_url: "http://insecure.example.com/scim/v2".to_string(),
1069 bearer_token: "tok".to_string(),
1070 timeout_secs: 10,
1071 };
1072 match ScimClient::new(config) {
1073 Err(e) => {
1074 let msg = format!("{e}");
1075 assert!(msg.contains("HTTPS"), "got: {msg}");
1076 }
1077 Ok(_) => panic!("expected error for HTTP URL"),
1078 }
1079 }
1080
1081 #[test]
1082 fn test_list_response_deserialization() {
1083 let json_str = r#"{
1084 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
1085 "totalResults": 2,
1086 "startIndex": 1,
1087 "itemsPerPage": 10,
1088 "Resources": [
1089 {
1090 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
1091 "userName": "alice"
1092 },
1093 {
1094 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
1095 "userName": "bob"
1096 }
1097 ]
1098 }"#;
1099
1100 let list: ListResponse<ScimUser> = serde_json::from_str(json_str).unwrap();
1101 assert_eq!(list.total_results, 2);
1102 assert_eq!(list.resources.len(), 2);
1103 assert_eq!(list.resources[0].user_name, "alice");
1104 assert_eq!(list.resources[1].user_name, "bob");
1105 }
1106}