scim_server/resource/
mod.rs

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