Skip to main content

raps_kernel/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Shared types used across the kernel
5
6use serde::{Deserialize, Serialize};
7
8/// Stored token with metadata for persistence
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct StoredToken {
11    pub access_token: String,
12    pub refresh_token: Option<String>,
13    pub expires_at: i64, // Unix timestamp
14    pub scopes: Vec<String>,
15}
16
17impl StoredToken {
18    pub fn is_valid(&self) -> bool {
19        let now = chrono::Utc::now().timestamp();
20        // Consider expired 60 seconds before actual expiry
21        self.expires_at > now + 60
22    }
23}
24
25/// Profile configuration for credential management
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ProfileConfig {
28    pub client_id: Option<String>,
29    pub client_secret: Option<String>,
30    pub base_url: Option<String>,
31    pub callback_url: Option<String>,
32    pub da_nickname: Option<String>,
33    #[serde(default = "default_use_keychain")]
34    pub use_keychain: bool,
35    /// Sticky context: default hub ID for commands that need it
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub context_hub_id: Option<String>,
38    /// Sticky context: default project ID for commands that need it
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub context_project_id: Option<String>,
41    /// Sticky context: default account ID for admin commands
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub context_account_id: Option<String>,
44}
45
46fn default_use_keychain() -> bool {
47    true
48}
49
50/// Profiles data containing all profiles and active profile name
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct ProfilesData {
53    pub active_profile: Option<String>,
54    #[serde(default)]
55    pub profiles: std::collections::HashMap<String, ProfileConfig>,
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    // ==================== StoredToken Tests ====================
63
64    #[test]
65    fn test_stored_token_serialization() {
66        let token = StoredToken {
67            access_token: "test_access_token".to_string(),
68            refresh_token: Some("test_refresh_token".to_string()),
69            expires_at: 1700000000,
70            scopes: vec!["data:read".to_string(), "data:write".to_string()],
71        };
72
73        let json = serde_json::to_string(&token).unwrap();
74        assert!(json.contains("test_access_token"));
75        assert!(json.contains("test_refresh_token"));
76        assert!(json.contains("1700000000"));
77        assert!(json.contains("data:read"));
78    }
79
80    #[test]
81    fn test_stored_token_deserialization() {
82        let json = r#"{
83            "access_token": "my_token",
84            "refresh_token": "my_refresh",
85            "expires_at": 1700000000,
86            "scopes": ["scope1", "scope2"]
87        }"#;
88
89        let token: StoredToken = serde_json::from_str(json).unwrap();
90        assert_eq!(token.access_token, "my_token");
91        assert_eq!(token.refresh_token, Some("my_refresh".to_string()));
92        assert_eq!(token.expires_at, 1700000000);
93        assert_eq!(token.scopes, vec!["scope1", "scope2"]);
94    }
95
96    #[test]
97    fn test_stored_token_without_refresh_token() {
98        let json = r#"{
99            "access_token": "my_token",
100            "refresh_token": null,
101            "expires_at": 1700000000,
102            "scopes": []
103        }"#;
104
105        let token: StoredToken = serde_json::from_str(json).unwrap();
106        assert_eq!(token.refresh_token, None);
107        assert!(token.scopes.is_empty());
108    }
109
110    #[test]
111    fn test_stored_token_is_valid_future_expiry() {
112        let future_timestamp = chrono::Utc::now().timestamp() + 3600; // 1 hour from now
113        let token = StoredToken {
114            access_token: "token".to_string(),
115            refresh_token: None,
116            expires_at: future_timestamp,
117            scopes: vec![],
118        };
119        assert!(token.is_valid());
120    }
121
122    #[test]
123    fn test_stored_token_is_valid_past_expiry() {
124        let past_timestamp = chrono::Utc::now().timestamp() - 3600; // 1 hour ago
125        let token = StoredToken {
126            access_token: "token".to_string(),
127            refresh_token: None,
128            expires_at: past_timestamp,
129            scopes: vec![],
130        };
131        assert!(!token.is_valid());
132    }
133
134    #[test]
135    fn test_stored_token_is_valid_buffer() {
136        // Token that expires in 30 seconds should be considered invalid (60s buffer)
137        let soon_timestamp = chrono::Utc::now().timestamp() + 30;
138        let token = StoredToken {
139            access_token: "token".to_string(),
140            refresh_token: None,
141            expires_at: soon_timestamp,
142            scopes: vec![],
143        };
144        assert!(!token.is_valid());
145    }
146
147    #[test]
148    fn test_stored_token_is_valid_just_outside_buffer() {
149        // Token that expires in 120 seconds should be valid
150        let timestamp = chrono::Utc::now().timestamp() + 120;
151        let token = StoredToken {
152            access_token: "token".to_string(),
153            refresh_token: None,
154            expires_at: timestamp,
155            scopes: vec![],
156        };
157        assert!(token.is_valid());
158    }
159
160    // ==================== ProfileConfig Tests ====================
161
162    #[test]
163    fn test_profile_config_default() {
164        let config = ProfileConfig::default();
165        assert!(config.client_id.is_none());
166        assert!(config.client_secret.is_none());
167        assert!(config.base_url.is_none());
168        assert!(config.callback_url.is_none());
169        assert!(config.da_nickname.is_none());
170        // Note: Default derive gives bool::default() = false for use_keychain
171        // The serde default only applies during deserialization
172        assert!(!config.use_keychain);
173        assert!(config.context_hub_id.is_none());
174        assert!(config.context_project_id.is_none());
175        assert!(config.context_account_id.is_none());
176    }
177
178    #[test]
179    fn test_profile_config_serialization() {
180        let config = ProfileConfig {
181            client_id: Some("my_client_id".to_string()),
182            client_secret: Some("my_secret".to_string()),
183            base_url: Some("https://custom.api.com".to_string()),
184            callback_url: Some("http://localhost:3000/callback".to_string()),
185            da_nickname: Some("my-nickname".to_string()),
186            use_keychain: true,
187            context_hub_id: None,
188            context_project_id: None,
189            context_account_id: None,
190        };
191
192        let json = serde_json::to_string(&config).unwrap();
193        assert!(json.contains("my_client_id"));
194        assert!(json.contains("my_secret"));
195        assert!(json.contains("https://custom.api.com"));
196        assert!(json.contains("my-nickname"));
197    }
198
199    #[test]
200    fn test_profile_config_deserialization() {
201        let json = r#"{
202            "client_id": "test_id",
203            "client_secret": "test_secret",
204            "base_url": "https://api.example.com",
205            "use_keychain": false
206        }"#;
207
208        let config: ProfileConfig = serde_json::from_str(json).unwrap();
209        assert_eq!(config.client_id, Some("test_id".to_string()));
210        assert_eq!(config.client_secret, Some("test_secret".to_string()));
211        assert_eq!(config.base_url, Some("https://api.example.com".to_string()));
212        assert!(!config.use_keychain);
213    }
214
215    #[test]
216    fn test_profile_config_deserialization_defaults() {
217        // Missing use_keychain should default to true
218        let json = r#"{"client_id": "test"}"#;
219        let config: ProfileConfig = serde_json::from_str(json).unwrap();
220        assert!(config.use_keychain);
221    }
222
223    // ==================== ProfilesData Tests ====================
224
225    #[test]
226    fn test_profiles_data_default() {
227        let data = ProfilesData::default();
228        assert!(data.active_profile.is_none());
229        assert!(data.profiles.is_empty());
230    }
231
232    #[test]
233    fn test_profiles_data_serialization() {
234        let mut profiles = std::collections::HashMap::new();
235        profiles.insert(
236            "default".to_string(),
237            ProfileConfig {
238                client_id: Some("id1".to_string()),
239                ..ProfileConfig::default()
240            },
241        );
242        profiles.insert(
243            "production".to_string(),
244            ProfileConfig {
245                client_id: Some("id2".to_string()),
246                ..ProfileConfig::default()
247            },
248        );
249
250        let data = ProfilesData {
251            active_profile: Some("default".to_string()),
252            profiles,
253        };
254
255        let json = serde_json::to_string(&data).unwrap();
256        assert!(json.contains("default"));
257        assert!(json.contains("production"));
258        assert!(json.contains("id1"));
259        assert!(json.contains("id2"));
260    }
261
262    #[test]
263    fn test_profiles_data_deserialization() {
264        let json = r#"{
265            "active_profile": "dev",
266            "profiles": {
267                "dev": {"client_id": "dev_id"},
268                "prod": {"client_id": "prod_id"}
269            }
270        }"#;
271
272        let data: ProfilesData = serde_json::from_str(json).unwrap();
273        assert_eq!(data.active_profile, Some("dev".to_string()));
274        assert_eq!(data.profiles.len(), 2);
275        assert_eq!(
276            data.profiles.get("dev").unwrap().client_id,
277            Some("dev_id".to_string())
278        );
279    }
280
281    #[test]
282    fn test_profiles_data_empty_profiles() {
283        let json = r#"{"active_profile": null}"#;
284        let data: ProfilesData = serde_json::from_str(json).unwrap();
285        assert!(data.active_profile.is_none());
286        assert!(data.profiles.is_empty());
287    }
288
289    #[test]
290    fn test_stored_token_roundtrip() {
291        let original = StoredToken {
292            access_token: "access123".to_string(),
293            refresh_token: Some("refresh456".to_string()),
294            expires_at: 1700000000,
295            scopes: vec!["read".to_string(), "write".to_string()],
296        };
297
298        let json = serde_json::to_string(&original).unwrap();
299        let deserialized: StoredToken = serde_json::from_str(&json).unwrap();
300
301        assert_eq!(original.access_token, deserialized.access_token);
302        assert_eq!(original.refresh_token, deserialized.refresh_token);
303        assert_eq!(original.expires_at, deserialized.expires_at);
304        assert_eq!(original.scopes, deserialized.scopes);
305    }
306
307    #[test]
308    fn test_profile_config_roundtrip() {
309        let original = ProfileConfig {
310            client_id: Some("client".to_string()),
311            client_secret: Some("secret".to_string()),
312            base_url: Some("https://api.com".to_string()),
313            callback_url: Some("http://localhost/callback".to_string()),
314            da_nickname: Some("nickname".to_string()),
315            use_keychain: false,
316            context_hub_id: Some("b.hub-123".to_string()),
317            context_project_id: Some("b.proj-456".to_string()),
318            context_account_id: Some("acc-789".to_string()),
319        };
320
321        let json = serde_json::to_string(&original).unwrap();
322        let deserialized: ProfileConfig = serde_json::from_str(&json).unwrap();
323
324        assert_eq!(original.client_id, deserialized.client_id);
325        assert_eq!(original.client_secret, deserialized.client_secret);
326        assert_eq!(original.base_url, deserialized.base_url);
327        assert_eq!(original.callback_url, deserialized.callback_url);
328        assert_eq!(original.da_nickname, deserialized.da_nickname);
329        assert_eq!(original.use_keychain, deserialized.use_keychain);
330        assert_eq!(original.context_hub_id, deserialized.context_hub_id);
331        assert_eq!(original.context_project_id, deserialized.context_project_id);
332        assert_eq!(original.context_account_id, deserialized.context_account_id);
333    }
334
335    // ==================== Context Fields Tests ====================
336
337    #[test]
338    fn test_profile_config_context_fields_default_none() {
339        let config = ProfileConfig::default();
340        assert!(config.context_hub_id.is_none());
341        assert!(config.context_project_id.is_none());
342        assert!(config.context_account_id.is_none());
343    }
344
345    #[test]
346    fn test_profile_config_context_fields_deserialization() {
347        let json = r#"{
348            "client_id": "test",
349            "context_hub_id": "b.hub-123",
350            "context_project_id": "b.proj-456",
351            "context_account_id": "acc-789"
352        }"#;
353        let config: ProfileConfig = serde_json::from_str(json).unwrap();
354        assert_eq!(config.context_hub_id.unwrap(), "b.hub-123");
355        assert_eq!(config.context_project_id.unwrap(), "b.proj-456");
356        assert_eq!(config.context_account_id.unwrap(), "acc-789");
357    }
358
359    #[test]
360    fn test_profile_config_context_fields_skip_none_serialization() {
361        let config = ProfileConfig::default();
362        let json = serde_json::to_string(&config).unwrap();
363        // Context fields should NOT appear when None
364        assert!(!json.contains("context_hub_id"));
365        assert!(!json.contains("context_project_id"));
366        assert!(!json.contains("context_account_id"));
367    }
368}