Skip to main content

uptrakit_web_api_types/
lib.rs

1/// Re-export `SecretString` so consumers (web-api, CLI, openapi-client) can
2/// use it without a direct `uptrakit-shared-types` dependency.
3pub use uptrakit_shared_types::SecretString;
4
5/// Re-export `PluginRole` so consumers can construct and match on role values
6/// without a direct `uptrakit-shared-types` dependency.
7pub use uptrakit_shared_types::PluginRole;
8
9pub mod access_presets;
10pub mod agents;
11pub mod api_tokens;
12pub mod audit_logs;
13pub mod auth;
14pub mod autodiscovery;
15pub mod batch_actions;
16pub mod command_validation;
17pub mod device_auth;
18pub mod discovery_allowlist;
19pub mod enrollment_tokens;
20pub mod error;
21pub mod events;
22pub mod host_tags;
23pub mod hosts;
24pub mod masked_url;
25pub mod notifications;
26pub mod oidc_auth;
27pub mod oidc_providers;
28pub mod pagination;
29pub mod permissions;
30pub mod plugin_config_test;
31pub mod plugin_configs;
32pub mod plugin_type_settings;
33pub mod prelude;
34pub mod profile;
35pub mod registration;
36pub mod roles;
37pub mod scheduler;
38pub mod server_cert;
39pub mod services;
40pub mod settings;
41pub mod settings_agent_certs;
42pub mod settings_auth;
43pub mod settings_ca;
44pub mod settings_combined;
45pub mod settings_network;
46pub mod settings_reset;
47pub mod system_services;
48pub use masked_url::MaskedUrl;
49pub mod settings_nats;
50pub mod settings_provider_github;
51pub mod settings_zeroconf;
52pub mod software_items;
53pub mod surfaces;
54pub mod system_alerts;
55pub mod system_enrollment_tokens;
56pub mod update_batches;
57pub mod update_history;
58pub mod users;
59pub mod validation;
60
61/// Default value for `enabled` fields in create-request types.
62///
63/// Used as `#[serde(default = "crate::default_enabled")]` in
64/// [`plugin_configs::CreatePluginConfigRequest`].
65pub fn default_enabled() -> bool {
66    true
67}
68
69/// Default value for the `featured` field in create-request types.
70///
71/// Used as `#[serde(default = "crate::default_featured")]` in
72/// [`software_items::CreateSoftwareItemRequest`].
73pub fn default_featured() -> bool {
74    true
75}
76
77#[cfg(test)]
78mod tests {
79    use crate::auth::{AuthResponse, UserResponse};
80    use crate::device_auth::DeviceAuthPollResponse;
81    use crate::error::ErrorResponse;
82    use crate::oidc_providers::CreateOidcProviderRequest;
83    use crate::permissions::Permission;
84    use crate::plugin_configs::CreatePluginConfigRequest;
85    use crate::registration::RegistrationMode;
86    use crate::software_items::CreateSoftwareItemRequest;
87    use crate::update_history::UpdateStatus;
88    use strum::IntoEnumIterator;
89    use uptrakit_shared_types::{DeviceAuthStatus, SecretString};
90    use uuid::Uuid;
91
92    // ── 1. Permission enum round-trip ─────────────────────────────────────
93
94    #[test]
95    fn permission_serde_round_trip() {
96        for perm in Permission::iter() {
97            let json = serde_json::to_string(&perm).unwrap();
98            let deserialized: Permission = serde_json::from_str(&json).unwrap();
99            assert_eq!(deserialized, perm);
100        }
101    }
102
103    #[test]
104    fn permission_as_str_values() {
105        assert_eq!(Permission::ViewSettings.as_str(), "view_settings");
106        assert_eq!(
107            Permission::ManageAuthSettings.as_str(),
108            "manage_auth_settings"
109        );
110        assert_eq!(Permission::ViewServices.as_str(), "view_services");
111        assert_eq!(Permission::UpdateServices.as_str(), "update_services");
112        assert_eq!(
113            Permission::ManageGlobalSettings.as_str(),
114            "manage_global_settings"
115        );
116    }
117
118    #[test]
119    fn permission_from_str_valid() {
120        assert_eq!(
121            "view_settings".parse::<Permission>().ok(),
122            Some(Permission::ViewSettings)
123        );
124        assert_eq!(
125            "manage_auth_settings".parse::<Permission>().ok(),
126            Some(Permission::ManageAuthSettings)
127        );
128        assert_eq!(
129            "view_services".parse::<Permission>().ok(),
130            Some(Permission::ViewServices)
131        );
132        assert_eq!(
133            "update_services".parse::<Permission>().ok(),
134            Some(Permission::UpdateServices)
135        );
136        assert_eq!(
137            "manage_global_settings".parse::<Permission>().ok(),
138            Some(Permission::ManageGlobalSettings)
139        );
140    }
141
142    #[test]
143    fn permission_from_str_invalid_returns_err() {
144        assert!("nonexistent".parse::<Permission>().is_err());
145        assert!("".parse::<Permission>().is_err());
146        assert!("VIEW_SETTINGS".parse::<Permission>().is_err());
147    }
148
149    #[test]
150    fn permission_display_matches_as_str() {
151        for perm in Permission::iter() {
152            assert_eq!(format!("{perm}"), perm.as_str());
153        }
154    }
155
156    #[test]
157    fn permission_iter_covers_all_variants() {
158        assert_eq!(Permission::iter().count(), 33);
159    }
160
161    #[test]
162    fn permission_as_str_round_trips_through_from_str() {
163        for perm in Permission::iter() {
164            let s = perm.as_str();
165            let parsed: Permission = s
166                .parse()
167                .expect("from_str should succeed for as_str output");
168            assert_eq!(parsed, perm);
169        }
170    }
171
172    // ── 2. RegistrationMode enum round-trip ──────────────────────────────
173
174    #[test]
175    fn registration_mode_serde_round_trip() {
176        for mode in RegistrationMode::iter() {
177            let json = serde_json::to_string(&mode).unwrap();
178            let deserialized: RegistrationMode = serde_json::from_str(&json).unwrap();
179            assert_eq!(deserialized, mode);
180        }
181    }
182
183    #[test]
184    fn registration_mode_as_str_values() {
185        assert_eq!(RegistrationMode::Open.as_str(), "open");
186        assert_eq!(RegistrationMode::Invite.as_str(), "invite");
187        assert_eq!(RegistrationMode::Closed.as_str(), "closed");
188    }
189
190    #[test]
191    fn registration_mode_from_str_valid() {
192        assert_eq!(
193            "open".parse::<RegistrationMode>().ok(),
194            Some(RegistrationMode::Open)
195        );
196        assert_eq!(
197            "invite".parse::<RegistrationMode>().ok(),
198            Some(RegistrationMode::Invite)
199        );
200        assert_eq!(
201            "closed".parse::<RegistrationMode>().ok(),
202            Some(RegistrationMode::Closed)
203        );
204    }
205
206    #[test]
207    fn registration_mode_from_str_invalid_returns_none() {
208        assert!("disabled".parse::<RegistrationMode>().is_err());
209        assert!("".parse::<RegistrationMode>().is_err());
210        assert!("OPEN".parse::<RegistrationMode>().is_err());
211    }
212
213    #[test]
214    fn registration_mode_as_str_round_trips_through_from_str() {
215        for mode in RegistrationMode::iter() {
216            let s = mode.as_str();
217            let parsed: RegistrationMode = s
218                .parse()
219                .expect("from_str should succeed for as_str output");
220            assert_eq!(parsed, mode);
221        }
222    }
223
224    #[test]
225    fn registration_mode_display_matches_as_str() {
226        for mode in RegistrationMode::iter() {
227            assert_eq!(format!("{mode}"), mode.as_str());
228        }
229    }
230
231    // ── 4. Serde defaults ─────────────────────────────────────────────────
232
233    #[test]
234    fn create_oidc_provider_request_default_scopes() {
235        let json = serde_json::json!({
236            "name": "Test Provider",
237            "slug": "test",
238            "issuer_url": "https://issuer.example.com",
239            "client_id": "client-id",
240            "client_secret": "client-secret"
241        });
242        let req: CreateOidcProviderRequest = serde_json::from_value(json).unwrap();
243        assert_eq!(req.scopes, "openid email profile groups");
244    }
245
246    #[test]
247    fn create_oidc_provider_request_default_auto_create() {
248        let json = serde_json::json!({
249            "name": "Test Provider",
250            "slug": "test",
251            "issuer_url": "https://issuer.example.com",
252            "client_id": "client-id",
253            "client_secret": "client-secret"
254        });
255        let req: CreateOidcProviderRequest = serde_json::from_value(json).unwrap();
256        assert!(req.auto_create_users);
257    }
258
259    #[test]
260    fn create_oidc_provider_request_explicit_overrides_defaults() {
261        let json = serde_json::json!({
262            "name": "Test Provider",
263            "slug": "test",
264            "issuer_url": "https://issuer.example.com",
265            "client_id": "client-id",
266            "client_secret": "client-secret",
267            "scopes": "openid",
268            "auto_create_users": false
269        });
270        let req: CreateOidcProviderRequest = serde_json::from_value(json).unwrap();
271        assert_eq!(req.scopes, "openid");
272        assert!(!req.auto_create_users);
273    }
274
275    #[test]
276    fn create_software_item_request_default_featured() {
277        let json = serde_json::json!({ "name": "Node.js" });
278        let req: CreateSoftwareItemRequest = serde_json::from_value(json).unwrap();
279        assert!(req.featured);
280    }
281
282    #[test]
283    fn create_software_item_request_explicit_featured_false() {
284        let json = serde_json::json!({
285            "name": "Node.js",
286            "featured": false
287        });
288        let req: CreateSoftwareItemRequest = serde_json::from_value(json).unwrap();
289        assert!(!req.featured);
290    }
291
292    #[test]
293    fn create_plugin_config_request_default_enabled() {
294        let json = serde_json::json!({
295            "name": "GitHub Releases",
296            "plugin_type": "releases_github",
297            "config": {}
298        });
299        let req: CreatePluginConfigRequest = serde_json::from_value(json).unwrap();
300        assert!(req.enabled);
301    }
302
303    #[test]
304    fn create_plugin_config_request_explicit_enabled_false() {
305        let json = serde_json::json!({
306            "name": "GitHub Releases",
307            "plugin_type": "releases_github",
308            "config": {},
309            "enabled": false
310        });
311        let req: CreatePluginConfigRequest = serde_json::from_value(json).unwrap();
312        assert!(!req.enabled);
313    }
314
315    // ── 5. Option fields serialize as null ─────────────────────────────────
316
317    #[test]
318    fn device_auth_poll_response_serializes_none_as_null() {
319        let resp = DeviceAuthPollResponse {
320            status: DeviceAuthStatus::Pending,
321            token: None,
322            token_name: None,
323        };
324        let json = serde_json::to_value(&resp).unwrap();
325        let obj = json.as_object().unwrap();
326
327        assert!(obj.contains_key("status"));
328        assert!(
329            obj.get("token").unwrap().is_null(),
330            "token should serialize as null when None"
331        );
332        assert!(
333            obj.get("token_name").unwrap().is_null(),
334            "token_name should serialize as null when None"
335        );
336    }
337
338    #[test]
339    fn device_auth_poll_response_includes_some_fields() {
340        let resp = DeviceAuthPollResponse {
341            status: DeviceAuthStatus::Authorized,
342            token: Some(SecretString::new("secret-token-value")),
343            token_name: Some("my-device".to_string()),
344        };
345        let json = serde_json::to_value(&resp).unwrap();
346        let obj = json.as_object().unwrap();
347
348        assert_eq!(obj.get("status").unwrap(), "authorized");
349        assert_eq!(obj.get("token").unwrap(), "secret-token-value");
350        assert_eq!(obj.get("token_name").unwrap(), "my-device");
351    }
352
353    #[test]
354    fn device_auth_poll_response_round_trip_with_none() {
355        let resp = DeviceAuthPollResponse {
356            status: DeviceAuthStatus::Pending,
357            token: None,
358            token_name: None,
359        };
360        let json = serde_json::to_string(&resp).unwrap();
361        let deserialized: DeviceAuthPollResponse = serde_json::from_str(&json).unwrap();
362        assert_eq!(deserialized.status, DeviceAuthStatus::Pending);
363        assert!(deserialized.token.is_none());
364        assert!(deserialized.token_name.is_none());
365    }
366
367    #[test]
368    fn device_auth_poll_response_round_trip_with_some() {
369        let resp = DeviceAuthPollResponse {
370            status: DeviceAuthStatus::Authorized,
371            token: Some(SecretString::new("tok")),
372            token_name: Some("dev".to_string()),
373        };
374        let json = serde_json::to_string(&resp).unwrap();
375        let deserialized: DeviceAuthPollResponse = serde_json::from_str(&json).unwrap();
376        assert_eq!(deserialized.status, DeviceAuthStatus::Authorized);
377        assert_eq!(
378            deserialized.token.as_ref().map(|t| t.expose_secret()),
379            Some("tok")
380        );
381        assert_eq!(deserialized.token_name.as_deref(), Some("dev"));
382    }
383
384    // ── 6. Representative struct round-trips ──────────────────────────────
385
386    #[test]
387    fn user_response_round_trip() {
388        let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("valid uuid");
389        let user = UserResponse {
390            id: user_id,
391            email: "owner@example.com".to_string(),
392            first_name: "Owner".to_string(),
393            last_name: "User".to_string(),
394            permissions: vec![
395                Permission::ViewSettings,
396                Permission::ManageAuthSettings,
397                Permission::ViewServices,
398                Permission::UpdateServices,
399                Permission::ManageGlobalSettings,
400            ],
401            has_pending_email_change: false,
402        };
403        let json = serde_json::to_string(&user).unwrap();
404        let deserialized: UserResponse = serde_json::from_str(&json).unwrap();
405
406        assert_eq!(deserialized.id, user_id);
407        assert_eq!(deserialized.email, "owner@example.com");
408        assert_eq!(deserialized.first_name, "Owner");
409        assert_eq!(deserialized.last_name, "User");
410        assert_eq!(deserialized.permissions.len(), 5);
411        assert_eq!(deserialized.permissions[0], Permission::ViewSettings);
412        assert_eq!(deserialized.permissions[1], Permission::ManageAuthSettings);
413        assert_eq!(deserialized.permissions[2], Permission::ViewServices);
414        assert_eq!(deserialized.permissions[3], Permission::UpdateServices);
415        assert_eq!(
416            deserialized.permissions[4],
417            Permission::ManageGlobalSettings
418        );
419    }
420
421    #[test]
422    fn user_response_empty_permissions() {
423        let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").expect("valid uuid");
424        let user = UserResponse {
425            id: user_id,
426            email: "viewer@example.com".to_string(),
427            first_name: "View".to_string(),
428            last_name: "Only".to_string(),
429            permissions: vec![],
430            has_pending_email_change: false,
431        };
432        let json = serde_json::to_string(&user).unwrap();
433        let deserialized: UserResponse = serde_json::from_str(&json).unwrap();
434
435        assert!(deserialized.permissions.is_empty());
436    }
437
438    #[test]
439    fn auth_response_round_trip() {
440        let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("valid uuid");
441        let auth = AuthResponse {
442            access_token: SecretString::new("eyJhbGciOiJIUzI1NiJ9.test"),
443            refresh_token: SecretString::new("refresh-token-value"),
444            expires_in: 3600,
445            token_type: "Bearer".to_string(),
446            user: UserResponse {
447                id: user_id,
448                email: "admin@example.com".to_string(),
449                first_name: "Admin".to_string(),
450                last_name: "User".to_string(),
451                permissions: vec![Permission::ViewSettings, Permission::UpdateServices],
452                has_pending_email_change: false,
453            },
454        };
455        let json = serde_json::to_string(&auth).unwrap();
456        let deserialized: AuthResponse = serde_json::from_str(&json).unwrap();
457
458        assert_eq!(
459            deserialized.access_token.expose_secret(),
460            "eyJhbGciOiJIUzI1NiJ9.test"
461        );
462        assert_eq!(
463            deserialized.refresh_token.expose_secret(),
464            "refresh-token-value"
465        );
466        assert_eq!(deserialized.expires_in, 3600);
467        assert_eq!(deserialized.token_type, "Bearer");
468        assert_eq!(deserialized.user.id, user_id);
469        assert_eq!(deserialized.user.email, "admin@example.com");
470        assert_eq!(deserialized.user.permissions.len(), 2);
471        assert_eq!(deserialized.user.permissions[0], Permission::ViewSettings);
472        assert_eq!(deserialized.user.permissions[1], Permission::UpdateServices);
473    }
474
475    // ── 6. UpdateStatus enum round-trip ─────────────────────────────────
476
477    #[test]
478    fn update_status_serde_round_trip() {
479        for status in UpdateStatus::iter() {
480            let json = serde_json::to_string(&status).unwrap();
481            let deserialized: UpdateStatus = serde_json::from_str(&json).unwrap();
482            assert_eq!(deserialized, status);
483        }
484    }
485
486    #[test]
487    fn update_status_as_str_values() {
488        assert_eq!(UpdateStatus::Queued.as_str(), "queued");
489        assert_eq!(UpdateStatus::Pending.as_str(), "pending");
490        assert_eq!(UpdateStatus::InProgress.as_str(), "in_progress");
491        assert_eq!(UpdateStatus::Completed.as_str(), "completed");
492        assert_eq!(UpdateStatus::Failed.as_str(), "failed");
493    }
494
495    #[test]
496    fn update_status_from_str_valid() {
497        assert_eq!(
498            "queued".parse::<UpdateStatus>().ok(),
499            Some(UpdateStatus::Queued)
500        );
501        assert_eq!(
502            "pending".parse::<UpdateStatus>().ok(),
503            Some(UpdateStatus::Pending)
504        );
505        assert_eq!(
506            "in_progress".parse::<UpdateStatus>().ok(),
507            Some(UpdateStatus::InProgress)
508        );
509        assert_eq!(
510            "completed".parse::<UpdateStatus>().ok(),
511            Some(UpdateStatus::Completed)
512        );
513        assert_eq!(
514            "failed".parse::<UpdateStatus>().ok(),
515            Some(UpdateStatus::Failed)
516        );
517    }
518
519    #[test]
520    fn update_status_from_str_invalid_returns_none() {
521        assert!("unknown".parse::<UpdateStatus>().is_err());
522        assert!("".parse::<UpdateStatus>().is_err());
523        assert!("PENDING".parse::<UpdateStatus>().is_err());
524    }
525
526    #[test]
527    fn update_status_as_str_round_trips_through_from_str() {
528        for status in UpdateStatus::iter() {
529            let s = status.as_str();
530            let parsed: UpdateStatus = s
531                .parse()
532                .expect("from_str should succeed for as_str output");
533            assert_eq!(parsed, status);
534        }
535    }
536
537    #[test]
538    fn update_status_display_matches_as_str() {
539        for status in UpdateStatus::iter() {
540            assert_eq!(format!("{status}"), status.as_str());
541        }
542    }
543
544    // ── 8. ErrorResponse round-trip ─────────────────────────────────────
545
546    #[test]
547    fn error_response_serialization_without_code() {
548        let resp = ErrorResponse {
549            error: "Something went wrong".to_string(),
550            code: None,
551        };
552        let json = serde_json::to_value(&resp).unwrap();
553        let obj = json.as_object().unwrap();
554
555        assert_eq!(obj.get("error").unwrap(), "Something went wrong");
556        assert!(
557            obj.get("code").unwrap().is_null(),
558            "code should serialize as null when None"
559        );
560    }
561
562    #[test]
563    fn error_response_serialization_with_code() {
564        let resp = ErrorResponse {
565            error: "Not found".to_string(),
566            code: Some("not_found".to_string()),
567        };
568        let json = serde_json::to_value(&resp).unwrap();
569        let obj = json.as_object().unwrap();
570
571        assert_eq!(obj.get("error").unwrap(), "Not found");
572        assert_eq!(obj.get("code").unwrap(), "not_found");
573    }
574
575    #[test]
576    fn error_response_round_trip_without_code() {
577        let resp = ErrorResponse {
578            error: "Bad request".to_string(),
579            code: None,
580        };
581        let json = serde_json::to_string(&resp).unwrap();
582        let deserialized: ErrorResponse = serde_json::from_str(&json).unwrap();
583
584        assert_eq!(deserialized.error, "Bad request");
585        assert!(deserialized.code.is_none());
586    }
587
588    #[test]
589    fn error_response_round_trip_with_code() {
590        let resp = ErrorResponse {
591            error: "Forbidden".to_string(),
592            code: Some("insufficient_permissions".to_string()),
593        };
594        let json = serde_json::to_string(&resp).unwrap();
595        let deserialized: ErrorResponse = serde_json::from_str(&json).unwrap();
596
597        assert_eq!(deserialized.error, "Forbidden");
598        assert_eq!(
599            deserialized.code.as_deref(),
600            Some("insufficient_permissions")
601        );
602    }
603
604    #[test]
605    fn error_response_deserialization_without_code_field() {
606        let json = r#"{"error":"test error"}"#;
607        let resp: ErrorResponse = serde_json::from_str(json).unwrap();
608
609        assert_eq!(resp.error, "test error");
610        assert!(resp.code.is_none());
611    }
612
613    // ── 9. Pagination ────────────────────────────────────────────────────
614
615    #[test]
616    fn pagination_resolve_defaults() {
617        use crate::pagination::{DEFAULT_PER_PAGE, PaginationParams};
618        let params = PaginationParams {
619            page: None,
620            per_page: None,
621        };
622        let resolved = params.resolve();
623        assert_eq!(resolved.page, 1);
624        assert_eq!(resolved.per_page, DEFAULT_PER_PAGE);
625    }
626
627    #[test]
628    fn pagination_resolve_explicit_values() {
629        use crate::pagination::PaginationParams;
630        let params = PaginationParams {
631            page: Some(3),
632            per_page: Some(50),
633        };
634        let resolved = params.resolve();
635        assert_eq!(resolved.page, 3);
636        assert_eq!(resolved.per_page, 50);
637    }
638
639    #[test]
640    fn pagination_resolve_clamps_page_zero_to_one() {
641        use crate::pagination::PaginationParams;
642        let params = PaginationParams {
643            page: Some(0),
644            per_page: None,
645        };
646        let resolved = params.resolve();
647        assert_eq!(resolved.page, 1);
648    }
649
650    #[test]
651    fn pagination_resolve_clamps_per_page_zero_to_one() {
652        use crate::pagination::PaginationParams;
653        let params = PaginationParams {
654            page: None,
655            per_page: Some(0),
656        };
657        let resolved = params.resolve();
658        assert_eq!(resolved.per_page, 1);
659    }
660
661    #[test]
662    fn pagination_resolve_clamps_per_page_above_max() {
663        use crate::pagination::{MAX_PER_PAGE, PaginationParams};
664        let params = PaginationParams {
665            page: None,
666            per_page: Some(5000),
667        };
668        let resolved = params.resolve();
669        assert_eq!(resolved.per_page, MAX_PER_PAGE);
670    }
671
672    #[test]
673    fn pagination_offset_calculation() {
674        use crate::pagination::ResolvedPagination;
675        let p = ResolvedPagination {
676            page: 1,
677            per_page: 20,
678        };
679        assert_eq!(p.offset(), 0);
680
681        let p = ResolvedPagination {
682            page: 2,
683            per_page: 20,
684        };
685        assert_eq!(p.offset(), 20);
686
687        let p = ResolvedPagination {
688            page: 3,
689            per_page: 50,
690        };
691        assert_eq!(p.offset(), 100);
692    }
693
694    #[test]
695    fn pagination_total_pages_calculation() {
696        use crate::pagination::ResolvedPagination;
697        let p = ResolvedPagination {
698            page: 1,
699            per_page: 20,
700        };
701        assert_eq!(p.total_pages(0), 0);
702        assert_eq!(p.total_pages(1), 1);
703        assert_eq!(p.total_pages(20), 1);
704        assert_eq!(p.total_pages(21), 2);
705        assert_eq!(p.total_pages(100), 5);
706    }
707
708    #[test]
709    fn paginated_response_serialization_round_trip() {
710        use crate::pagination::{PaginatedResponse, ResolvedPagination};
711        let pagination = ResolvedPagination {
712            page: 2,
713            per_page: 10,
714        };
715        let resp = PaginatedResponse::new(vec!["a".to_string(), "b".to_string()], 25, pagination);
716
717        let json = serde_json::to_string(&resp).unwrap();
718        let deserialized: PaginatedResponse<String> = serde_json::from_str(&json).unwrap();
719
720        assert_eq!(deserialized.items, vec!["a", "b"]);
721        assert_eq!(deserialized.total, 25);
722        assert_eq!(deserialized.page, 2);
723        assert_eq!(deserialized.per_page, 10);
724        assert_eq!(deserialized.total_pages, 3);
725    }
726
727    #[test]
728    fn paginated_response_empty_items() {
729        use crate::pagination::{PaginatedResponse, ResolvedPagination};
730        let pagination = ResolvedPagination {
731            page: 1,
732            per_page: 20,
733        };
734        let resp = PaginatedResponse::<String>::new(vec![], 0, pagination);
735
736        assert!(resp.items.is_empty());
737        assert_eq!(resp.total, 0);
738        assert_eq!(resp.total_pages, 0);
739    }
740
741    // ── 10. Prelude compile check ───────────────────────────────────────
742
743    #[test]
744    fn prelude_re_exports_resolve() {
745        use crate::prelude::*;
746
747        // Auth
748        let _: AuthResponse;
749        let _: UserResponse;
750
751        // Services
752        let _ = ServiceStatus::Pending;
753
754        // Hosts
755        let _: HostResponse;
756
757        // Software items
758        let _: SoftwareItemResponse;
759        let _ = TriggerUpdateStatus::Pending;
760
761        // Plugin configs
762        let _: PluginConfigResponse;
763
764        // Update history
765        let _ = UpdateStatus::Pending;
766
767        // API tokens
768        let _: ApiTokenListResponse;
769        let _: CreateApiTokenResponse;
770
771        // OIDC
772        let _: OidcProviderResponse;
773        let _: AuthMethodsResponse;
774
775        // Notifications
776        let _ = NotificationEventType::UpdateAvailable;
777        let _ = NotificationDeliveryStatus::Pending;
778        let _: NotificationChannelResponse;
779        let _: NotificationRuleResponse;
780        let _: NotificationLogResponse;
781        let _: TestNotificationResponse;
782
783        // Common
784        let _: ErrorResponse;
785        let _: PaginatedResponse<String>;
786        let _: PaginationParams;
787        let _ = Permission::ViewSettings;
788        let _ = RegistrationMode::Open;
789    }
790
791    // ── 11. Request type validation ─────────────────────────────────────
792
793    #[test]
794    fn register_request_valid() {
795        use crate::auth::RegisterRequest;
796        use crate::validation::Validate;
797        let req = RegisterRequest {
798            email: "user@example.com".to_string(),
799            first_name: "John".to_string(),
800            last_name: "Doe".to_string(),
801            password: SecretString::new("password12345678"),
802            registration_token: None,
803        };
804        assert!(req.validate().is_ok());
805    }
806
807    #[test]
808    fn register_request_email_missing_at() {
809        use crate::auth::RegisterRequest;
810        use crate::validation::Validate;
811        let req = RegisterRequest {
812            email: "invalid-email".to_string(),
813            first_name: "John".to_string(),
814            last_name: "Doe".to_string(),
815            password: SecretString::new("password12345678"),
816            registration_token: None,
817        };
818        let err = req.validate().unwrap_err();
819        assert_eq!(err.field, "email");
820    }
821
822    #[test]
823    fn register_request_password_too_short() {
824        use crate::auth::RegisterRequest;
825        use crate::validation::Validate;
826        let req = RegisterRequest {
827            email: "user@example.com".to_string(),
828            first_name: "John".to_string(),
829            last_name: "Doe".to_string(),
830            password: SecretString::new("short"),
831            registration_token: None,
832        };
833        let err = req.validate().unwrap_err();
834        assert_eq!(err.field, "password");
835    }
836
837    #[test]
838    fn register_request_empty_first_name() {
839        use crate::auth::RegisterRequest;
840        use crate::validation::Validate;
841        let req = RegisterRequest {
842            email: "user@example.com".to_string(),
843            first_name: "".to_string(),
844            last_name: "Doe".to_string(),
845            password: SecretString::new("password12345678"),
846            registration_token: None,
847        };
848        let err = req.validate().unwrap_err();
849        assert_eq!(err.field, "first_name");
850    }
851
852    #[test]
853    fn login_request_valid() {
854        use crate::auth::LoginRequest;
855        use crate::validation::Validate;
856        let req = LoginRequest {
857            email: "user@example.com".to_string(),
858            password: SecretString::new("any-password"),
859        };
860        assert!(req.validate().is_ok());
861    }
862
863    #[test]
864    fn login_request_empty_password() {
865        use crate::auth::LoginRequest;
866        use crate::validation::Validate;
867        let req = LoginRequest {
868            email: "user@example.com".to_string(),
869            password: SecretString::new(""),
870        };
871        let err = req.validate().unwrap_err();
872        assert_eq!(err.field, "password");
873    }
874
875    #[test]
876    fn create_oidc_provider_valid() {
877        use crate::validation::Validate;
878        let req = CreateOidcProviderRequest {
879            name: "Test Provider".to_string(),
880            slug: "test-provider".to_string(),
881            logo_url: None,
882            issuer_url: "https://issuer.example.com".to_string(),
883            client_id: "client-id".to_string(),
884            client_secret: SecretString::new("secret"),
885            scopes: "openid email".to_string(),
886            auto_create_users: true,
887            allow_private_network_issuers: Some(true),
888            role_claim_path: None,
889            role_mapping: Default::default(),
890        };
891        assert!(req.validate().is_ok());
892    }
893
894    #[test]
895    fn create_oidc_provider_invalid_slug() {
896        use crate::validation::Validate;
897        let req = CreateOidcProviderRequest {
898            name: "Test".to_string(),
899            slug: "INVALID_SLUG".to_string(),
900            logo_url: None,
901            issuer_url: "https://issuer.example.com".to_string(),
902            client_id: "client-id".to_string(),
903            client_secret: SecretString::new("secret"),
904            scopes: "openid".to_string(),
905            auto_create_users: true,
906            allow_private_network_issuers: Some(true),
907            role_claim_path: None,
908            role_mapping: Default::default(),
909        };
910        let err = req.validate().unwrap_err();
911        assert_eq!(err.field, "slug");
912    }
913
914    #[test]
915    fn create_oidc_provider_invalid_issuer_url() {
916        use crate::validation::Validate;
917        let req = CreateOidcProviderRequest {
918            name: "Test".to_string(),
919            slug: "test".to_string(),
920            logo_url: None,
921            issuer_url: "ftp://issuer.example.com".to_string(),
922            client_id: "client-id".to_string(),
923            client_secret: SecretString::new("secret"),
924            scopes: "openid".to_string(),
925            auto_create_users: true,
926            allow_private_network_issuers: Some(true),
927            role_claim_path: None,
928            role_mapping: Default::default(),
929        };
930        let err = req.validate().unwrap_err();
931        assert_eq!(err.field, "issuer_url");
932    }
933
934    #[test]
935    fn create_software_item_valid() {
936        use crate::validation::Validate;
937        let req = CreateSoftwareItemRequest {
938            name: "Node.js".to_string(),
939            featured: true,
940            icon_url: None,
941        };
942        assert!(req.validate().is_ok());
943    }
944
945    #[test]
946    fn create_software_item_empty_name_fails() {
947        use crate::validation::Validate;
948        let req = CreateSoftwareItemRequest {
949            name: "".to_string(),
950            featured: true,
951            icon_url: None,
952        };
953        let err = req.validate().unwrap_err();
954        assert_eq!(err.field, "name");
955    }
956
957    #[test]
958    fn create_plugin_config_valid() {
959        use crate::validation::Validate;
960        use uptrakit_shared_types::plugin_ids;
961        let req = CreatePluginConfigRequest {
962            name: "GitHub Releases".to_string(),
963            plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
964            config: serde_json::json!({}),
965            enabled: true,
966        };
967        assert!(req.validate().is_ok());
968    }
969
970    #[test]
971    fn create_plugin_config_empty_name() {
972        use crate::validation::Validate;
973        use uptrakit_shared_types::plugin_ids;
974        let req = CreatePluginConfigRequest {
975            name: "".to_string(),
976            plugin_type: plugin_ids::RELEASES_GITHUB.clone(),
977            config: serde_json::json!({}),
978            enabled: true,
979        };
980        let err = req.validate().unwrap_err();
981        assert_eq!(err.field, "name");
982    }
983
984    #[test]
985    fn update_scheduled_task_valid_interval() {
986        use crate::scheduler::UpdateScheduledTaskRequest;
987        use crate::validation::Validate;
988        let req = UpdateScheduledTaskRequest {
989            interval_seconds: Some(300),
990            jitter_seconds: Some(30),
991            enabled: None,
992            task_config: None,
993        };
994        assert!(req.validate().is_ok());
995    }
996
997    #[test]
998    fn update_scheduled_task_invalid_interval() {
999        use crate::scheduler::UpdateScheduledTaskRequest;
1000        use crate::validation::Validate;
1001        let req = UpdateScheduledTaskRequest {
1002            interval_seconds: Some(0),
1003            jitter_seconds: None,
1004            enabled: None,
1005            task_config: None,
1006        };
1007        let err = req.validate().unwrap_err();
1008        assert_eq!(err.field, "interval_seconds");
1009    }
1010
1011    #[test]
1012    fn update_network_settings_valid() {
1013        use crate::settings_network::UpdateNetworkSettingsRequest;
1014        use crate::validation::Validate;
1015        let req = UpdateNetworkSettingsRequest {
1016            trusted_proxies: Some(vec!["10.0.0.0/8".to_string()]),
1017            real_ip_header: Some("X-Real-IP".to_string()),
1018            sans: None,
1019            https_addr: None,
1020            forwarded_client_cert_info_header: None,
1021            forwarded_client_cert_pem_header: None,
1022            pki_addr: Some("https://pki.example.com".to_string()),
1023            regenerate_cert: None,
1024        };
1025        assert!(req.validate().is_ok());
1026    }
1027
1028    #[test]
1029    fn update_network_settings_empty_trusted_proxy() {
1030        use crate::settings_network::UpdateNetworkSettingsRequest;
1031        use crate::validation::Validate;
1032        let req = UpdateNetworkSettingsRequest {
1033            trusted_proxies: Some(vec!["".to_string()]),
1034            real_ip_header: None,
1035            sans: None,
1036            https_addr: None,
1037            forwarded_client_cert_info_header: None,
1038            forwarded_client_cert_pem_header: None,
1039            pki_addr: None,
1040            regenerate_cert: None,
1041        };
1042        let err = req.validate().unwrap_err();
1043        assert_eq!(err.field, "trusted_proxies");
1044    }
1045
1046    // ── 12. Device auth SSE types ────────────────────────────────────
1047
1048    #[test]
1049    fn device_auth_authorized_sse_round_trip() {
1050        use crate::device_auth::DeviceAuthAuthorizedSse;
1051        let event = DeviceAuthAuthorizedSse {
1052            token: SecretString::new("secret-api-token"),
1053            token_name: "my-device".to_string(),
1054        };
1055        let json = serde_json::to_string(&event).unwrap();
1056        let deserialized: DeviceAuthAuthorizedSse = serde_json::from_str(&json).unwrap();
1057        assert_eq!(deserialized.token.expose_secret(), "secret-api-token");
1058        assert_eq!(deserialized.token_name, "my-device");
1059    }
1060
1061    #[test]
1062    fn device_auth_expired_sse_round_trip() {
1063        use crate::device_auth::DeviceAuthExpiredSse;
1064        let event = DeviceAuthExpiredSse {
1065            message: "Device flow expired".to_string(),
1066        };
1067        let json = serde_json::to_string(&event).unwrap();
1068        let deserialized: DeviceAuthExpiredSse = serde_json::from_str(&json).unwrap();
1069        assert_eq!(deserialized.message, "Device flow expired");
1070    }
1071}