scim_server/resource/
mod.rs

1//! Resource model and provider trait for SCIM resources.
2//!
3//! This module defines the core resource abstractions that users implement
4//! to provide data access for SCIM operations. The design emphasizes
5//! type safety and async patterns while keeping the interface simple.
6//!
7//! # Module Organization
8//!
9//! * [`core`] - Core types like `Resource`, `RequestContext`, `ScimOperation`, and `ListQuery`
10//! * [`types`] - Domain-specific data structures like `EmailAddress`
11//! * [`mapper`] - Schema mapping functionality for converting between formats
12//! * [`handlers`] - Dynamic handler infrastructure for runtime resource operations
13//! * [`provider`] - The main `ResourceProvider` trait for data access
14
15pub mod builder;
16pub mod conditional_provider;
17pub mod context;
18pub mod core;
19pub mod handlers;
20pub mod mapper;
21pub mod provider;
22pub mod resource;
23pub mod serialization;
24pub mod tenant;
25pub mod types;
26pub mod value_objects;
27pub mod version;
28
29// Re-export all public types to maintain API compatibility
30pub use builder::ResourceBuilder;
31pub use context::{ListQuery, RequestContext};
32pub use resource::Resource;
33pub use tenant::{IsolationLevel, TenantContext, TenantPermissions};
34// Re-export ScimOperation from multi_tenant module for backward compatibility
35pub use crate::multi_tenant::ScimOperation;
36pub use handlers::{AttributeHandler, DynamicResource, ResourceHandler, SchemaResourceBuilder};
37pub use mapper::{DatabaseMapper, SchemaMapper};
38pub use provider::{ResourceProvider, ResourceProviderExt};
39pub use types::EmailAddress;
40pub use value_objects::{
41    Address, EmailAddress as EmailAddressValue, ExternalId, Meta, Name, PhoneNumber, ResourceId,
42    SchemaUri, UserName,
43};
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use serde_json::json;
49
50    #[test]
51    fn test_resource_creation() {
52        let data = json!({
53            "userName": "testuser",
54            "displayName": "Test User"
55        });
56        let resource = Resource::from_json("User".to_string(), data).unwrap();
57
58        assert_eq!(resource.resource_type, "User");
59        assert_eq!(resource.get_username(), Some("testuser"));
60    }
61
62    #[test]
63    fn test_resource_id_extraction() {
64        let data = json!({
65            "id": "12345",
66            "userName": "testuser"
67        });
68        let resource = Resource::from_json("User".to_string(), data).unwrap();
69
70        assert_eq!(resource.get_id(), Some("12345"));
71    }
72
73    #[test]
74    fn test_resource_schemas() {
75        let data = json!({
76            "userName": "testuser"
77        });
78        let resource = Resource::from_json("User".to_string(), data).unwrap();
79
80        let schemas = resource.get_schemas();
81        assert_eq!(schemas.len(), 1);
82        assert_eq!(schemas[0], "urn:ietf:params:scim:schemas:core:2.0:User");
83    }
84
85    #[test]
86    fn test_email_extraction() {
87        let data = json!({
88            "userName": "testuser",
89            "emails": [
90                {
91                    "value": "test@example.com",
92                    "type": "work",
93                    "primary": true
94                }
95            ]
96        });
97        let resource = Resource::from_json("User".to_string(), data).unwrap();
98
99        let emails = resource.get_emails().expect("Should have emails");
100        assert_eq!(emails.len(), 1);
101        let email = emails.get(0).expect("Should have first email");
102        assert_eq!(email.value(), "test@example.com");
103    }
104
105    #[test]
106    fn test_request_context_creation() {
107        let context = RequestContext::new("test-request".to_string());
108        assert!(!context.request_id.is_empty());
109
110        let context_with_id = RequestContext::new("test-123".to_string());
111        assert_eq!(context_with_id.request_id, "test-123");
112    }
113
114    #[test]
115    fn test_resource_active_status() {
116        let active_data = json!({
117            "userName": "testuser",
118            "active": true
119        });
120        let active_resource = Resource::from_json("User".to_string(), active_data).unwrap();
121        assert!(active_resource.is_active());
122
123        let inactive_data = json!({
124            "userName": "testuser",
125            "active": false
126        });
127        let inactive_resource = Resource::from_json("User".to_string(), inactive_data).unwrap();
128        assert!(!inactive_resource.is_active());
129
130        let no_active_data = json!({
131            "userName": "testuser"
132        });
133        let default_resource = Resource::from_json("User".to_string(), no_active_data).unwrap();
134        assert!(default_resource.is_active()); // Default to true
135    }
136
137    #[test]
138    fn test_meta_extraction_from_json() {
139        use chrono::{TimeZone, Utc};
140
141        // Test resource with valid meta
142        let data_with_meta = json!({
143            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
144            "id": "12345",
145            "userName": "testuser",
146            "meta": {
147                "resourceType": "User",
148                "created": "2023-01-01T12:00:00Z",
149                "lastModified": "2023-01-02T12:00:00Z",
150                "location": "https://example.com/Users/12345",
151                "version": "W/\"12345-1672574400000\""
152            }
153        });
154
155        let resource = Resource::from_json("User".to_string(), data_with_meta).unwrap();
156        let meta = resource.get_meta().unwrap();
157
158        assert_eq!(meta.resource_type(), "User");
159        assert_eq!(
160            meta.created(),
161            Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap()
162        );
163        assert_eq!(
164            meta.last_modified(),
165            Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap()
166        );
167        assert_eq!(meta.location(), Some("https://example.com/Users/12345"));
168        assert_eq!(meta.version(), Some("W/\"12345-1672574400000\""));
169    }
170
171    #[test]
172    fn test_meta_extraction_minimal() {
173        // Test resource with minimal meta
174        let data_minimal_meta = json!({
175            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
176            "userName": "testuser",
177            "meta": {
178                "resourceType": "User",
179                "created": "2023-01-01T12:00:00Z",
180                "lastModified": "2023-01-01T12:00:00Z"
181            }
182        });
183
184        let resource = Resource::from_json("User".to_string(), data_minimal_meta).unwrap();
185        let meta = resource.get_meta().unwrap();
186
187        assert_eq!(meta.resource_type(), "User");
188        assert_eq!(meta.location(), None);
189        assert_eq!(meta.version(), None);
190    }
191
192    #[test]
193    fn test_meta_extraction_invalid_datetime_returns_error() {
194        // Test resource with invalid datetime format returns validation error
195        let data_invalid_meta = json!({
196            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
197            "userName": "testuser",
198            "meta": {
199                "resourceType": "User",
200                "created": "invalid-date",
201                "lastModified": "2023-01-01T12:00:00Z"
202            }
203        });
204
205        let result = Resource::from_json("User".to_string(), data_invalid_meta);
206        assert!(result.is_err());
207        match result.unwrap_err() {
208            crate::error::ValidationError::InvalidCreatedDateTime => {
209                // Expected error
210            }
211            other => panic!("Expected InvalidCreatedDateTime, got {:?}", other),
212        }
213    }
214
215    #[test]
216    fn test_meta_extraction_incomplete_is_ignored() {
217        // Test resource with incomplete meta is ignored (for backward compatibility)
218        let data_incomplete_meta = json!({
219            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
220            "userName": "testuser",
221            "meta": {
222                "resourceType": "User"
223                // Missing created and lastModified
224            }
225        });
226
227        let resource = Resource::from_json("User".to_string(), data_incomplete_meta).unwrap();
228        assert!(resource.get_meta().is_none());
229    }
230
231    #[test]
232    fn test_meta_extraction_missing() {
233        // Test resource without meta
234        let data_no_meta = json!({
235            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
236            "userName": "testuser"
237        });
238
239        let resource = Resource::from_json("User".to_string(), data_no_meta).unwrap();
240        assert!(resource.get_meta().is_none());
241    }
242
243    #[test]
244    fn test_set_meta() {
245        use crate::resource::value_objects::Meta;
246        use chrono::Utc;
247
248        let mut resource = Resource::from_json(
249            "User".to_string(),
250            json!({
251                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
252                "userName": "testuser"
253            }),
254        )
255        .unwrap();
256
257        let now = Utc::now();
258        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
259        resource.set_meta(meta.clone());
260
261        assert!(resource.get_meta().is_some());
262        assert_eq!(resource.get_meta().unwrap().resource_type(), "User");
263
264        // Test that meta is also in JSON representation
265        let json_output = resource.to_json().unwrap();
266        assert!(json_output.get("meta").is_some());
267    }
268
269    #[test]
270    fn test_create_meta() {
271        let mut resource = Resource::from_json(
272            "User".to_string(),
273            json!({
274                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
275                "id": "12345",
276                "userName": "testuser"
277            }),
278        )
279        .unwrap();
280
281        resource.create_meta("https://example.com").unwrap();
282
283        let meta = resource.get_meta().unwrap();
284        assert_eq!(meta.resource_type(), "User");
285        assert_eq!(meta.created(), meta.last_modified());
286        assert_eq!(meta.location(), Some("https://example.com/Users/12345"));
287    }
288
289    #[test]
290    fn test_update_meta() {
291        use crate::resource::value_objects::Meta;
292        use chrono::Utc;
293        use std::thread;
294        use std::time::Duration;
295
296        let mut resource = Resource::from_json(
297            "User".to_string(),
298            json!({
299                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
300                "userName": "testuser"
301            }),
302        )
303        .unwrap();
304
305        let now = Utc::now();
306        let meta = Meta::new_simple("User".to_string(), now, now).unwrap();
307        resource.set_meta(meta);
308
309        let original_modified = resource.get_meta().unwrap().last_modified();
310
311        // Wait a bit to ensure timestamp difference
312        thread::sleep(Duration::from_millis(10));
313
314        resource.update_meta();
315
316        let updated_modified = resource.get_meta().unwrap().last_modified();
317        assert!(updated_modified > original_modified);
318        assert_eq!(resource.get_meta().unwrap().created(), now);
319    }
320
321    #[test]
322    fn test_add_metadata_legacy_compatibility() {
323        let mut resource = Resource::from_json(
324            "User".to_string(),
325            json!({
326                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
327                "id": "12345",
328                "userName": "testuser"
329            }),
330        )
331        .unwrap();
332
333        resource.add_metadata(
334            "https://example.com",
335            "2023-01-01T12:00:00Z",
336            "2023-01-02T12:00:00Z",
337        );
338
339        // Should create Meta value object
340        let meta = resource.get_meta().unwrap();
341        assert_eq!(meta.resource_type(), "User");
342        assert_eq!(meta.location(), Some("https://example.com/Users/12345"));
343
344        // Should also be in JSON attributes for backward compatibility
345        let json_output = resource.to_json().unwrap();
346        assert!(json_output.get("meta").is_some());
347    }
348
349    #[test]
350    fn test_meta_serialization_in_to_json() {
351        use crate::resource::value_objects::Meta;
352        use chrono::{TimeZone, Utc};
353
354        let mut resource = Resource::from_json(
355            "User".to_string(),
356            json!({
357                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
358                "userName": "testuser"
359            }),
360        )
361        .unwrap();
362
363        let created = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
364        let modified = Utc.with_ymd_and_hms(2023, 1, 2, 12, 0, 0).unwrap();
365        let meta = Meta::new(
366            "User".to_string(),
367            created,
368            modified,
369            Some("https://example.com/Users/123".to_string()),
370            Some("W/\"123-456\"".to_string()),
371        )
372        .unwrap();
373
374        resource.set_meta(meta);
375
376        let json_output = resource.to_json().unwrap();
377        let meta_json = json_output.get("meta").unwrap();
378
379        assert_eq!(
380            meta_json.get("resourceType").unwrap().as_str().unwrap(),
381            "User"
382        );
383        assert!(
384            meta_json
385                .get("created")
386                .unwrap()
387                .as_str()
388                .unwrap()
389                .starts_with("2023-01-01T12:00:00")
390        );
391        assert!(
392            meta_json
393                .get("lastModified")
394                .unwrap()
395                .as_str()
396                .unwrap()
397                .starts_with("2023-01-02T12:00:00")
398        );
399        assert_eq!(
400            meta_json.get("location").unwrap().as_str().unwrap(),
401            "https://example.com/Users/123"
402        );
403        assert_eq!(
404            meta_json.get("version").unwrap().as_str().unwrap(),
405            "W/\"123-456\""
406        );
407    }
408
409    #[test]
410    fn test_resource_with_name_extraction() {
411        let data = json!({
412            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
413            "userName": "testuser",
414            "name": {
415                "formatted": "John Doe",
416                "familyName": "Doe",
417                "givenName": "John"
418            }
419        });
420
421        let resource = Resource::from_json("User".to_string(), data).unwrap();
422
423        assert!(resource.get_name().is_some());
424        let name = resource.get_name().unwrap();
425        assert_eq!(name.formatted(), Some("John Doe"));
426        assert_eq!(name.family_name(), Some("Doe"));
427        assert_eq!(name.given_name(), Some("John"));
428    }
429
430    #[test]
431    fn test_resource_with_addresses_extraction() {
432        let data = json!({
433            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
434            "userName": "testuser",
435            "addresses": [
436                {
437                    "type": "work",
438                    "streetAddress": "123 Main St",
439                    "locality": "Anytown",
440                    "region": "CA",
441                    "postalCode": "12345",
442                    "country": "US",
443                    "primary": true
444                }
445            ]
446        });
447
448        let resource = Resource::from_json("User".to_string(), data).unwrap();
449
450        let addresses = resource.get_addresses().expect("Should have addresses");
451        assert_eq!(addresses.len(), 1);
452        let address = addresses.get(0).expect("Should have first address");
453        assert_eq!(address.address_type(), Some("work"));
454        assert_eq!(address.street_address(), Some("123 Main St"));
455        assert_eq!(address.locality(), Some("Anytown"));
456        assert_eq!(address.region(), Some("CA"));
457        assert_eq!(address.postal_code(), Some("12345"));
458        assert_eq!(address.country(), Some("US"));
459        assert_eq!(address.is_primary(), true);
460    }
461
462    #[test]
463    fn test_resource_with_phone_numbers_extraction() {
464        let data = json!({
465            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
466            "userName": "testuser",
467            "phoneNumbers": [
468                {
469                    "value": "tel:+1-555-555-5555",
470                    "type": "work",
471                    "primary": true
472                }
473            ]
474        });
475
476        let resource = Resource::from_json("User".to_string(), data).unwrap();
477
478        let phones = resource
479            .get_phone_numbers()
480            .expect("Should have phone numbers");
481        assert_eq!(phones.len(), 1);
482        let phone = phones.get(0).expect("Should have first phone");
483        assert_eq!(phone.value(), "tel:+1-555-555-5555");
484        assert_eq!(phone.phone_type(), Some("work"));
485        assert_eq!(phone.is_primary(), true);
486    }
487
488    #[test]
489    fn test_resource_builder_basic() {
490        use crate::resource::value_objects::{ResourceId, UserName};
491
492        let resource = ResourceBuilder::new("User".to_string())
493            .with_id(ResourceId::new("123".to_string()).unwrap())
494            .with_username(UserName::new("jdoe".to_string()).unwrap())
495            .with_attribute("displayName", json!("John Doe"))
496            .build()
497            .unwrap();
498
499        assert_eq!(resource.resource_type, "User");
500        assert_eq!(resource.get_id(), Some("123"));
501        assert_eq!(resource.get_username(), Some("jdoe"));
502        assert_eq!(
503            resource.get_attribute("displayName"),
504            Some(&json!("John Doe"))
505        );
506        assert_eq!(resource.schemas.len(), 1);
507        assert_eq!(
508            resource.schemas[0].as_str(),
509            "urn:ietf:params:scim:schemas:core:2.0:User"
510        );
511    }
512
513    #[test]
514    fn test_resource_builder_with_complex_attributes() {
515        use crate::resource::value_objects::{Address, Name, PhoneNumber};
516
517        let name = Name::new_simple("John".to_string(), "Doe".to_string()).unwrap();
518        let address = Address::new_work(
519            "123 Main St".to_string(),
520            "Anytown".to_string(),
521            "CA".to_string(),
522            "12345".to_string(),
523            "US".to_string(),
524        )
525        .unwrap();
526        let phone = PhoneNumber::new_work("tel:+1-555-555-5555".to_string()).unwrap();
527
528        let resource = ResourceBuilder::new("User".to_string())
529            .with_name(name)
530            .add_address(address)
531            .add_phone_number(phone)
532            .build()
533            .unwrap();
534
535        assert!(resource.get_name().is_some());
536        assert_eq!(resource.get_addresses().unwrap().len(), 1);
537        assert_eq!(resource.get_phone_numbers().unwrap().len(), 1);
538
539        // Test serialization includes all fields
540        let json_output = resource.to_json().unwrap();
541        assert!(json_output.get("name").is_some());
542        assert!(json_output.get("addresses").is_some());
543        assert!(json_output.get("phoneNumbers").is_some());
544    }
545
546    #[test]
547    fn test_resource_builder_with_meta() {
548        use crate::resource::value_objects::ResourceId;
549
550        let resource = ResourceBuilder::new("User".to_string())
551            .with_id(ResourceId::new("123".to_string()).unwrap())
552            .build_with_meta("https://example.com")
553            .unwrap();
554
555        assert!(resource.get_meta().is_some());
556        let meta = resource.get_meta().unwrap();
557        assert_eq!(meta.resource_type(), "User");
558        assert_eq!(meta.location(), Some("https://example.com/Users/123"));
559    }
560
561    #[test]
562    fn test_resource_builder_validation() {
563        // Test that builder validates required fields
564        let builder = ResourceBuilder::new("User".to_string());
565        // Remove default schema to test validation
566        let builder_no_schema = builder.with_schemas(vec![]);
567        let result = builder_no_schema.build();
568        assert!(result.is_err());
569    }
570
571    #[test]
572    fn test_resource_setter_methods() {
573        use crate::resource::value_objects::{Address, Name, PhoneNumber};
574
575        let mut resource = Resource::from_json(
576            "User".to_string(),
577            json!({
578                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
579                "userName": "testuser"
580            }),
581        )
582        .unwrap();
583
584        // Test name setter
585        let name = Name::new_simple("Jane".to_string(), "Smith".to_string()).unwrap();
586        resource.set_name(name);
587        assert!(resource.get_name().is_some());
588        assert_eq!(resource.get_name().unwrap().given_name(), Some("Jane"));
589
590        // Test address setter
591        let address = Address::new(
592            None,
593            Some("456 Oak Ave".to_string()),
594            Some("Hometown".to_string()),
595            Some("NY".to_string()),
596            Some("67890".to_string()),
597            Some("US".to_string()),
598            Some("home".to_string()),
599            Some(false),
600        )
601        .unwrap();
602        resource.add_address(address).unwrap();
603        let addresses = resource.get_addresses().expect("Should have addresses");
604        assert_eq!(addresses.len(), 1);
605        let address = addresses.get(0).expect("Should have first address");
606        assert_eq!(address.address_type(), Some("home"));
607
608        // Test phone number setter
609        let phone = PhoneNumber::new_mobile("tel:+1-555-123-4567".to_string()).unwrap();
610        resource.add_phone_number(phone).unwrap();
611        let phones = resource
612            .get_phone_numbers()
613            .expect("Should have phone numbers");
614        assert_eq!(phones.len(), 1);
615        let phone = phones.get(0).expect("Should have first phone");
616        assert_eq!(phone.phone_type(), Some("mobile"));
617    }
618
619    #[test]
620    fn test_resource_json_round_trip_with_complex_attributes() {
621        let data = json!({
622            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
623            "id": "123",
624            "userName": "jdoe",
625            "name": {
626                "formatted": "John Doe",
627                "familyName": "Doe",
628                "givenName": "John"
629            },
630            "addresses": [
631                {
632                    "type": "work",
633                    "streetAddress": "123 Main St",
634                    "locality": "Anytown",
635                    "region": "CA",
636                    "postalCode": "12345",
637                    "country": "US",
638                    "primary": true
639                }
640            ],
641            "phoneNumbers": [
642                {
643                    "value": "tel:+1-555-555-5555",
644                    "type": "work",
645                    "primary": true
646                }
647            ]
648        });
649
650        let resource = Resource::from_json("User".to_string(), data).unwrap();
651        let json_output = resource.to_json().unwrap();
652
653        // Verify all complex attributes are preserved
654        assert!(json_output.get("name").is_some());
655        assert!(json_output.get("addresses").is_some());
656        assert!(json_output.get("phoneNumbers").is_some());
657
658        // Verify the structured data is correct
659        let name_json = json_output.get("name").unwrap();
660        assert_eq!(
661            name_json.get("formatted").unwrap().as_str().unwrap(),
662            "John Doe"
663        );
664
665        let addresses_json = json_output.get("addresses").unwrap().as_array().unwrap();
666        assert_eq!(addresses_json.len(), 1);
667        assert_eq!(
668            addresses_json[0].get("type").unwrap().as_str().unwrap(),
669            "work"
670        );
671
672        let phones_json = json_output.get("phoneNumbers").unwrap().as_array().unwrap();
673        assert_eq!(phones_json.len(), 1);
674        assert_eq!(
675            phones_json[0].get("value").unwrap().as_str().unwrap(),
676            "tel:+1-555-555-5555"
677        );
678    }
679
680    #[test]
681    fn test_resource_invalid_complex_attributes() {
682        // Test invalid name structure
683        let invalid_name_data = json!({
684            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
685            "userName": "testuser",
686            "name": "should be object not string"
687        });
688        let result = Resource::from_json("User".to_string(), invalid_name_data);
689        assert!(result.is_err());
690
691        // Test invalid addresses structure
692        let invalid_addresses_data = json!({
693            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
694            "userName": "testuser",
695            "addresses": "should be array not string"
696        });
697        let result = Resource::from_json("User".to_string(), invalid_addresses_data);
698        assert!(result.is_err());
699
700        // Test invalid phone numbers structure
701        let invalid_phones_data = json!({
702            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
703            "userName": "testuser",
704            "phoneNumbers": "should be array not string"
705        });
706        let result = Resource::from_json("User".to_string(), invalid_phones_data);
707        assert!(result.is_err());
708    }
709}