1use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::str::FromStr;
8use strum::{Display, EnumString};
9
10use super::error::RealmError;
11use crate::storage::db::get_database;
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Display, EnumString)]
15pub enum RealmStatus {
16 #[default]
17 Active,
18 Inactive,
19 Suspended,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Realm {
25 pub id: u32,
27 pub name: String,
28 pub status: RealmStatus,
29 pub enabled: bool,
30 pub expires_at: Option<u64>,
31 pub created_at: u64,
32 pub updated_at: Option<u64>,
33 pub secret_current: String,
35 pub secret_previous: Option<(String, u64)>,
37}
38
39impl Default for Realm {
40 fn default() -> Self {
41 let now = Utc::now().timestamp() as u64;
42 Self {
43 id: 0,
44 name: String::new(),
45 status: RealmStatus::Active,
46 enabled: true,
47 expires_at: None,
48 created_at: now,
49 updated_at: None,
50 secret_current: String::new(),
51 secret_previous: None,
52 }
53 }
54}
55
56impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for Realm {
57 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
58 use sqlx::Row;
59 let id: i64 = row.try_get("id")?;
60 let name: String = row.try_get("name")?;
61 let status_str: String = row.try_get("status")?;
62 let enabled: bool = row.try_get::<i32, _>("enabled")? != 0;
63 let expires_at: Option<i64> = row.try_get("expires_at")?;
64 let created_at: i64 = row.try_get("created_at")?;
65 let updated_at: Option<i64> = row.try_get("updated_at")?;
66 let secret_current: String = row.try_get("secret_current")?;
67 let secret_previous_hash: Option<String> = row.try_get("secret_previous_hash")?;
68 let secret_previous_valid_until: Option<i64> =
69 row.try_get("secret_previous_valid_until")?;
70
71 let secret_previous = match (secret_previous_hash, secret_previous_valid_until) {
72 (Some(hash), Some(valid_until)) if !hash.is_empty() => Some((hash, valid_until as u64)),
73 _ => None,
74 };
75
76 Ok(Self {
77 id: id as u32,
78 name,
79 status: RealmStatus::from_str(&status_str).unwrap_or_default(),
80 enabled,
81 expires_at: expires_at.map(|v| v as u64),
82 created_at: created_at as u64,
83 updated_at: updated_at.map(|v| v as u64),
84 secret_current,
85 secret_previous,
86 })
87 }
88}
89
90impl Realm {
91 pub async fn create(name: String, secret_hash: String) -> Result<Self, RealmError> {
96 let db = get_database();
97 let pool = db.get_pool();
98 let now = Utc::now().timestamp();
99
100 let result = sqlx::query(
101 "INSERT INTO realm (name, status, enabled, created_at, secret_current)
102 VALUES (?, 'Active', 1, ?, ?)",
103 )
104 .bind(&name)
105 .bind(now)
106 .bind(&secret_hash)
107 .execute(pool)
108 .await?;
109
110 let id = result.last_insert_rowid() as u32;
111
112 Ok(Self {
113 id,
114 name,
115 status: RealmStatus::Active,
116 enabled: true,
117 expires_at: None,
118 created_at: now as u64,
119 updated_at: None,
120 secret_current: secret_hash,
121 secret_previous: None,
122 })
123 }
124
125 pub async fn upsert_managed(
127 id: u32,
128 name: String,
129 status: RealmStatus,
130 enabled: bool,
131 expires_at: Option<u64>,
132 secret_current: String,
133 secret_previous: Option<(String, u64)>,
134 ) -> Result<Self, RealmError> {
135 if id == 0 {
136 return Err(RealmError::ValidationError(
137 "managed realm id must be greater than 0".to_string(),
138 ));
139 }
140 if secret_current.trim().is_empty() {
141 return Err(RealmError::ValidationError(
142 "managed realm secret_current must not be empty".to_string(),
143 ));
144 }
145
146 let db = get_database();
147 let pool = db.get_pool();
148 let now = Utc::now().timestamp();
149 let (prev_hash, prev_valid_until) = match &secret_previous {
150 Some((hash, valid_until)) if !hash.trim().is_empty() => {
151 (Some(hash.as_str()), Some(*valid_until as i64))
152 }
153 _ => (None, None),
154 };
155
156 sqlx::query(
157 "INSERT INTO realm (
158 id, name, status, enabled, expires_at, created_at, updated_at,
159 secret_current, secret_previous_hash, secret_previous_valid_until
160 )
161 VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
162 ON CONFLICT(id) DO UPDATE SET
163 name = excluded.name,
164 status = excluded.status,
165 enabled = excluded.enabled,
166 expires_at = excluded.expires_at,
167 updated_at = ?,
168 secret_current = excluded.secret_current,
169 secret_previous_hash = excluded.secret_previous_hash,
170 secret_previous_valid_until = excluded.secret_previous_valid_until",
171 )
172 .bind(id as i64)
173 .bind(&name)
174 .bind(status.to_string())
175 .bind(enabled as i32)
176 .bind(expires_at.map(|v| v as i64))
177 .bind(now)
178 .bind(&secret_current)
179 .bind(prev_hash)
180 .bind(prev_valid_until)
181 .bind(now)
182 .execute(pool)
183 .await?;
184
185 Self::get(id).await?.ok_or(RealmError::NotFound)
186 }
187
188 pub async fn save(&mut self) -> Result<(), RealmError> {
190 let db = get_database();
191 let pool = db.get_pool();
192 let now = Utc::now().timestamp();
193 self.updated_at = Some(now as u64);
194
195 let (prev_hash, prev_valid_until) = match &self.secret_previous {
196 Some((hash, valid_until)) => (Some(hash.as_str()), Some(*valid_until as i64)),
197 None => (None, None),
198 };
199
200 sqlx::query(
201 "UPDATE realm SET name = ?, status = ?, enabled = ?, expires_at = ?,
202 updated_at = ?, secret_current = ?,
203 secret_previous_hash = ?, secret_previous_valid_until = ?
204 WHERE id = ?",
205 )
206 .bind(&self.name)
207 .bind(self.status.to_string())
208 .bind(self.enabled as i32)
209 .bind(self.expires_at.map(|v| v as i64))
210 .bind(now)
211 .bind(&self.secret_current)
212 .bind(prev_hash)
213 .bind(prev_valid_until)
214 .bind(self.id as i64)
215 .execute(pool)
216 .await?;
217
218 Ok(())
219 }
220
221 pub async fn get(id: u32) -> Result<Option<Self>, RealmError> {
223 let db = get_database();
224 let pool = db.get_pool();
225
226 let result = sqlx::query_as::<_, Realm>(
227 "SELECT id, name, status, enabled, expires_at, created_at, updated_at,
228 secret_current, secret_previous_hash, secret_previous_valid_until
229 FROM realm WHERE id = ?",
230 )
231 .bind(id as i64)
232 .fetch_optional(pool)
233 .await?;
234
235 Ok(result)
236 }
237
238 pub async fn get_by_name(name: &str) -> Result<Option<Self>, RealmError> {
240 let db = get_database();
241 let pool = db.get_pool();
242
243 let result = sqlx::query_as::<_, Realm>(
244 "SELECT id, name, status, enabled, expires_at, created_at, updated_at,
245 secret_current, secret_previous_hash, secret_previous_valid_until
246 FROM realm WHERE name = ?",
247 )
248 .bind(name)
249 .fetch_optional(pool)
250 .await?;
251
252 Ok(result)
253 }
254
255 pub async fn get_all() -> Result<Vec<Self>, RealmError> {
257 let db = get_database();
258 let pool = db.get_pool();
259
260 let realms = sqlx::query_as::<_, Realm>(
261 "SELECT id, name, status, enabled, expires_at, created_at, updated_at,
262 secret_current, secret_previous_hash, secret_previous_valid_until
263 FROM realm",
264 )
265 .fetch_all(pool)
266 .await?;
267
268 Ok(realms)
269 }
270
271 pub async fn delete(id: u32) -> Result<u64, RealmError> {
273 let db = get_database();
274 let pool = db.get_pool();
275
276 let result = sqlx::query("DELETE FROM realm WHERE id = ?")
277 .bind(id as i64)
278 .execute(pool)
279 .await?;
280
281 Ok(result.rows_affected())
282 }
283
284 pub async fn soft_delete(id: u32) -> Result<bool, RealmError> {
286 let Some(mut realm) = Self::get(id).await? else {
287 return Ok(false);
288 };
289
290 realm.enabled = false;
291 realm.status = RealmStatus::Inactive;
292 realm.save().await?;
293 Ok(true)
294 }
295
296 pub fn is_expired(&self) -> bool {
297 if let Some(expires_at) = self.expires_at {
298 let now = Utc::now().timestamp() as u64;
299 now > expires_at
300 } else {
301 false
302 }
303 }
304
305 pub fn is_active(&self) -> bool {
306 self.status == RealmStatus::Active && self.enabled && !self.is_expired()
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::util::test_utils::utils::setup_test_db;
314 use serial_test::serial;
315
316 #[test]
317 fn test_realm_status_display() {
318 assert_eq!(RealmStatus::Active.to_string(), "Active");
319 assert_eq!(RealmStatus::Inactive.to_string(), "Inactive");
320 assert_eq!(RealmStatus::Suspended.to_string(), "Suspended");
321 }
322
323 #[test]
324 fn test_realm_status_parse() {
325 assert_eq!(
326 RealmStatus::from_str("Active").unwrap(),
327 RealmStatus::Active
328 );
329 assert_eq!(
330 RealmStatus::from_str("Inactive").unwrap(),
331 RealmStatus::Inactive
332 );
333 assert_eq!(
334 RealmStatus::from_str("Suspended").unwrap(),
335 RealmStatus::Suspended
336 );
337 }
338
339 #[test]
340 fn test_realm_default() {
341 let realm = Realm::default();
342 assert_eq!(realm.id, 0);
343 assert_eq!(realm.status, RealmStatus::Active);
344 assert!(realm.enabled);
345 assert!(realm.secret_current.is_empty());
346 assert!(realm.secret_previous.is_none());
347 }
348
349 #[test]
350 fn test_realm_expired() {
351 let mut realm = Realm::default();
352 let past_time = Utc::now().timestamp() as u64 - 3600;
353 realm.expires_at = Some(past_time);
354 assert!(realm.is_expired());
355 assert!(!realm.is_active());
356 }
357
358 #[test]
359 fn test_realm_active() {
360 let mut realm = Realm::default();
361 let future_time = Utc::now().timestamp() as u64 + 3600;
362 realm.expires_at = Some(future_time);
363 assert!(!realm.is_expired());
364 assert!(realm.is_active());
365 }
366
367 #[tokio::test]
368 #[serial]
369 async fn test_upsert_managed_uses_external_id_and_updates() -> anyhow::Result<()> {
370 setup_test_db().await?;
371
372 let realm = Realm::upsert_managed(
373 424_242,
374 "managed-one".to_string(),
375 RealmStatus::Active,
376 true,
377 Some(1_900_000_000),
378 "hash-current".to_string(),
379 None,
380 )
381 .await?;
382 assert_eq!(realm.id, 424_242);
383 assert_eq!(realm.name, "managed-one");
384 assert_eq!(realm.secret_current, "hash-current");
385
386 let updated = Realm::upsert_managed(
387 424_242,
388 "managed-one-renamed".to_string(),
389 RealmStatus::Suspended,
390 false,
391 Some(1_900_000_001),
392 "hash-current-2".to_string(),
393 Some(("hash-prev".to_string(), 1_800_000_000)),
394 )
395 .await?;
396 assert_eq!(updated.id, 424_242);
397 assert_eq!(updated.name, "managed-one-renamed");
398 assert_eq!(updated.status, RealmStatus::Suspended);
399 assert!(!updated.enabled);
400 assert_eq!(updated.secret_current, "hash-current-2");
401 assert_eq!(
402 updated.secret_previous,
403 Some(("hash-prev".to_string(), 1_800_000_000))
404 );
405
406 Ok(())
407 }
408
409 #[tokio::test]
410 #[serial]
411 async fn test_soft_delete_marks_realm_inactive() -> anyhow::Result<()> {
412 setup_test_db().await?;
413
414 Realm::upsert_managed(
415 424_243,
416 "managed-delete".to_string(),
417 RealmStatus::Active,
418 true,
419 None,
420 "delete-current".to_string(),
421 None,
422 )
423 .await?;
424
425 assert!(Realm::soft_delete(424_243).await?);
426 let realm = Realm::get(424_243).await?.expect("realm should remain");
427 assert_eq!(realm.status, RealmStatus::Inactive);
428 assert!(!realm.enabled);
429 assert!(!Realm::soft_delete(999_999).await?);
430
431 Ok(())
432 }
433
434 #[test]
435 fn test_realm_suspended_not_active() {
436 let realm = Realm {
437 status: RealmStatus::Suspended,
438 ..Default::default()
439 };
440 assert!(!realm.is_active());
441 }
442
443 #[test]
444 fn test_realm_disabled_not_active() {
445 let realm = Realm {
446 enabled: false,
447 ..Default::default()
448 };
449 assert!(!realm.is_active());
450 }
451}