db.rs
⎇
Raw
1use std::path::Path;
2use std::sync::Arc;
3
4use rusqlite::{Connection, OptionalExtension, params};
5
6const SCHEMA_VERSION: i64 = 2;
7
8#[derive(Debug, Clone)]
9pub struct User {
10 pub id: i64,
11 pub name: String,
12 pub is_admin: bool,
13 /// Disabled users cannot sign in and their sessions are rejected.
14 pub active: bool,
15}
16
17#[derive(Debug, Clone)]
18pub struct RootRow {
19 pub id: i64,
20 /// Path relative to the server root; "." means the whole root.
21 pub path: String,
22 /// "rw" or "ro"
23 pub mode: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct ShareRow {
28 pub id: i64,
29 pub token: String,
30 pub creator_id: i64,
31 /// Path of the shared item relative to the server root.
32 pub target: String,
33 pub is_file: bool,
34 /// "rw" or "ro"
35 pub mode: String,
36 pub created_at: String,
37 pub expires_at: Option<String>,
38}
39
40impl ShareRow {
41 pub fn is_expired(&self) -> bool {
42 match &self.expires_at {
43 Some(e) => chrono::DateTime::parse_from_rfc3339(e)
44 .map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
45 .unwrap_or(false),
46 None => false,
47 }
48 }
49}
50
51#[derive(Clone)]
52pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
53
54impl std::fmt::Debug for Db {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("Db").finish()
57 }
58}
59
60impl Db {
61 pub async fn open(path: &Path) -> anyhow::Result<Self> {
62 if let Some(parent) = path.parent() {
63 if !parent.as_os_str().is_empty() {
64 std::fs::create_dir_all(parent)?;
65 }
66 }
67 let conn = Connection::open(path)?;
68 conn.pragma_update(None, "journal_mode", "WAL")?;
69 conn.pragma_update(None, "foreign_keys", "ON")?;
70 conn.pragma_update(None, "busy_timeout", "5000")?;
71 Self::migrate(&conn)?;
72 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
73 }
74
75 /// Open a fresh in-memory database (used by tests — no temp file needed).
76 pub async fn open_in_memory() -> anyhow::Result<Self> {
77 let conn = Connection::open_in_memory()?;
78 conn.pragma_update(None, "foreign_keys", "ON")?;
79 conn.pragma_update(None, "busy_timeout", "5000")?;
80 Self::migrate(&conn)?;
81 Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
82 }
83
84 fn migrate(conn: &Connection) -> rusqlite::Result<()> {
85 conn.execute(
86 "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
87 [],
88 )?;
89 let version: i64 = conn
90 .query_row(
91 "SELECT value FROM meta WHERE key = 'schema_version'",
92 [],
93 |r| r.get::<_, String>(0),
94 )
95 .optional()?
96 .and_then(|v| v.parse().ok())
97 .unwrap_or(0);
98
99 if version < 1 {
100 conn.execute_batch(SCHEMA_V1)?;
101 }
102 if version < 2 {
103 // User management (M7): a disabled flag so admins can suspend
104 // accounts without deleting them.
105 conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
106 }
107 conn.execute(
108 "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
109 [SCHEMA_VERSION.to_string()],
110 )?;
111 Ok(())
112 }
113
114 // ---------- users ----------
115
116 pub async fn user_count(&self) -> i64 {
117 let c = self.0.lock().await;
118 c.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
119 .unwrap_or(0)
120 }
121
122 /// Create the first admin account with the whole root visible (read-write).
123 /// Only valid while no users exist (enforced by the caller).
124 pub async fn create_admin(&self, name: &str, pass_hash: &str) -> Result<User, rusqlite::Error> {
125 let mut c = self.0.lock().await;
126 let tx = c.transaction()?;
127 tx.execute(
128 "INSERT INTO users (name, pass_hash, is_admin, created_at)
129 VALUES (?1, ?2, 1, ?3)",
130 params![name, pass_hash, now()],
131 )?;
132 let user_id = tx.last_insert_rowid();
133 tx.execute(
134 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, '.', 'rw')",
135 params![user_id],
136 )?;
137 tx.commit()?;
138 Ok(User {
139 id: user_id,
140 name: name.to_string(),
141 is_admin: true,
142 active: true,
143 })
144 }
145
146 pub async fn verify_password(&self, name: &str, password: &str) -> Option<User> {
147 let c = self.0.lock().await;
148 let row: Option<(i64, String, bool, String, bool)> = c
149 .query_row(
150 "SELECT id, name, is_admin != 0, pass_hash, active != 0 FROM users WHERE name = ?1",
151 [name],
152 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
153 )
154 .optional()
155 .ok()
156 .flatten();
157 let (id, name, is_admin, hash, active) = row?;
158 if !active {
159 return None;
160 }
161 crate::auth::verify_password(password, &hash).then_some(User {
162 id,
163 name,
164 is_admin,
165 active,
166 })
167 }
168
169 // ---------- sessions ----------
170
171 pub async fn create_session(&self, user_id: i64, token: &str) -> Result<(), rusqlite::Error> {
172 let c = self.0.lock().await;
173 c.execute(
174 "INSERT INTO sessions (token, user_id, created_at, last_seen_at)
175 VALUES (?1, ?2, ?3, ?4)",
176 params![token, user_id, now(), now()],
177 )?;
178 Ok(())
179 }
180
181 pub async fn delete_session(&self, token: &str) -> Result<(), rusqlite::Error> {
182 let c = self.0.lock().await;
183 c.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
184 Ok(())
185 }
186
187 pub async fn session_user(&self, token: &str) -> Option<User> {
188 let c = self.0.lock().await;
189 c.query_row(
190 "SELECT u.id, u.name, u.is_admin != 0, u.active != 0
191 FROM sessions s JOIN users u ON u.id = s.user_id
192 WHERE s.token = ?1 AND u.active = 1",
193 [token],
194 |r| {
195 Ok(User {
196 id: r.get(0)?,
197 name: r.get(1)?,
198 is_admin: r.get(2)?,
199 active: r.get(3)?,
200 })
201 },
202 )
203 .ok()
204 }
205
206 // ---------- roots ----------
207
208 pub async fn user_roots(&self, user_id: i64) -> Vec<RootRow> {
209 let c = self.0.lock().await;
210 let mut out = Vec::new();
211 let Ok(mut stmt) =
212 c.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")
213 else {
214 return out;
215 };
216 if let Ok(rows) = stmt.query_map([user_id], |r| {
217 Ok(RootRow {
218 id: r.get(0)?,
219 path: r.get(1)?,
220 mode: r.get(2)?,
221 })
222 }) {
223 out.extend(rows.flatten());
224 }
225 out
226 }
227
228 // ---------- admin: user management (M7) ----------
229
230 pub async fn all_users(&self) -> Vec<User> {
231 let c = self.0.lock().await;
232 let mut out = Vec::new();
233 if let Ok(mut stmt) =
234 c.prepare("SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id")
235 {
236 if let Ok(rows) = stmt.query_map([], |r| {
237 Ok(User {
238 id: r.get(0)?,
239 name: r.get(1)?,
240 is_admin: r.get(2)?,
241 active: r.get(3)?,
242 })
243 }) {
244 out.extend(rows.flatten());
245 }
246 }
247 out
248 }
249
250 pub async fn find_user_by_id(&self, id: i64) -> Option<User> {
251 let c = self.0.lock().await;
252 c.query_row(
253 "SELECT id, name, is_admin != 0, active != 0 FROM users WHERE id = ?1",
254 [id],
255 |r| {
256 Ok(User {
257 id: r.get(0)?,
258 name: r.get(1)?,
259 is_admin: r.get(2)?,
260 active: r.get(3)?,
261 })
262 },
263 )
264 .ok()
265 }
266
267 pub async fn find_user_by_name(&self, name: &str) -> Option<User> {
268 let c = self.0.lock().await;
269 c.query_row(
270 "SELECT id, name, is_admin != 0, active != 0 FROM users WHERE name = ?1",
271 [name],
272 |r| {
273 Ok(User {
274 id: r.get(0)?,
275 name: r.get(1)?,
276 is_admin: r.get(2)?,
277 active: r.get(3)?,
278 })
279 },
280 )
281 .ok()
282 }
283
284 pub async fn count_admins(&self) -> i64 {
285 let c = self.0.lock().await;
286 c.query_row(
287 "SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
288 [],
289 |r| r.get(0),
290 )
291 .unwrap_or(0)
292 }
293
294 /// Create a user with the given roots (path, mode) pairs.
295 pub async fn create_user(
296 &self,
297 name: &str,
298 pass_hash: &str,
299 is_admin: bool,
300 roots: &[(String, String)],
301 ) -> Result<User, rusqlite::Error> {
302 let mut c = self.0.lock().await;
303 let tx = c.transaction()?;
304 tx.execute(
305 "INSERT INTO users (name, pass_hash, is_admin, active, created_at)
306 VALUES (?1, ?2, ?3, 1, ?4)",
307 params![name, pass_hash, is_admin as i64, now()],
308 )?;
309 let user_id = tx.last_insert_rowid();
310 for (path, mode) in roots {
311 tx.execute(
312 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
313 params![user_id, path, mode],
314 )?;
315 }
316 tx.commit()?;
317 Ok(User {
318 id: user_id,
319 name: name.to_string(),
320 is_admin,
321 active: true,
322 })
323 }
324
325 pub async fn update_user_password(
326 &self,
327 id: i64,
328 pass_hash: &str,
329 ) -> Result<(), rusqlite::Error> {
330 let c = self.0.lock().await;
331 c.execute(
332 "UPDATE users SET pass_hash = ?1 WHERE id = ?2",
333 params![pass_hash, id],
334 )?;
335 Ok(())
336 }
337
338 pub async fn set_user_admin(&self, id: i64, is_admin: bool) -> Result<(), rusqlite::Error> {
339 let c = self.0.lock().await;
340 c.execute(
341 "UPDATE users SET is_admin = ?1 WHERE id = ?2",
342 params![is_admin as i64, id],
343 )?;
344 Ok(())
345 }
346
347 pub async fn set_user_active(&self, id: i64, active: bool) -> Result<(), rusqlite::Error> {
348 let c = self.0.lock().await;
349 c.execute(
350 "UPDATE users SET active = ?1 WHERE id = ?2",
351 params![active as i64, id],
352 )?;
353 Ok(())
354 }
355
356 pub async fn delete_user(&self, id: i64) -> bool {
357 let c = self.0.lock().await;
358 c.execute("DELETE FROM users WHERE id = ?1", [id])
359 .ok()
360 .map(|n| n > 0)
361 .unwrap_or(false)
362 }
363
364 /// Replace a user's roots with the given (path, mode) pairs.
365 pub async fn set_user_roots(
366 &self,
367 user_id: i64,
368 roots: &[(String, String)],
369 ) -> Result<(), rusqlite::Error> {
370 let mut c = self.0.lock().await;
371 let tx = c.transaction()?;
372 tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [user_id])?;
373 for (path, mode) in roots {
374 tx.execute(
375 "INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
376 params![user_id, path, mode],
377 )?;
378 }
379 tx.commit()?;
380 Ok(())
381 }
382
383 // ---------- shares ----------
384
385 pub async fn create_share(
386 &self,
387 creator_id: i64,
388 token: &str,
389 target: &str,
390 is_file: bool,
391 mode: &str,
392 expires_at: Option<&str>,
393 ) -> Result<ShareRow, rusqlite::Error> {
394 let c = self.0.lock().await;
395 c.execute(
396 "INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
397 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
398 params![
399 token,
400 creator_id,
401 target,
402 is_file as i64,
403 mode,
404 now(),
405 expires_at
406 ],
407 )?;
408 let id = c.last_insert_rowid();
409 Ok(ShareRow {
410 id,
411 token: token.to_string(),
412 creator_id,
413 target: target.to_string(),
414 is_file,
415 mode: mode.to_string(),
416 created_at: now(),
417 expires_at: expires_at.map(|s| s.to_string()),
418 })
419 }
420
421 pub async fn share_by_token(&self, token: &str) -> Option<ShareRow> {
422 let c = self.0.lock().await;
423 let sql = SHARE_BY_TOKEN.as_str();
424 c.query_row(sql, [token], map_share).ok()
425 }
426
427 pub async fn user_shares(&self, creator_id: i64) -> Vec<ShareRow> {
428 let c = self.0.lock().await;
429 let mut out = Vec::new();
430 let sql = USER_SHARES.as_str();
431 if let Ok(mut stmt) = c.prepare(sql) {
432 if let Ok(rows) = stmt.query_map([creator_id], map_share) {
433 out.extend(rows.flatten());
434 }
435 }
436 out
437 }
438
439 pub async fn delete_share(&self, id: i64, creator_id: i64) -> bool {
440 let c = self.0.lock().await;
441 c.execute(
442 "DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
443 params![id, creator_id],
444 )
445 .ok()
446 .map(|n| n > 0)
447 .unwrap_or(false)
448 }
449
450 // ---------- settings ----------
451
452 pub async fn get_setting(&self, key: &str) -> Option<String> {
453 let c = self.0.lock().await;
454 c.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
455 r.get(0)
456 })
457 .ok()
458 }
459
460 pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), rusqlite::Error> {
461 let c = self.0.lock().await;
462 c.execute(
463 "INSERT INTO settings (key, value) VALUES (?1, ?2)
464 ON CONFLICT(key) DO UPDATE SET value = ?2",
465 params![key, value],
466 )?;
467 Ok(())
468 }
469
470 /// Whether users may create writable (read-write) shares. Off by default;
471 /// the admin setting gates it.
472 pub async fn allow_writable_shares(&self) -> bool {
473 self.get_setting("allow_writable_shares").await.as_deref() == Some("1")
474 }
475
476 pub async fn set_allow_writable_shares(&self, v: bool) -> Result<(), rusqlite::Error> {
477 self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
478 .await
479 }
480}
481
482const SHARE_COLS: &str = "id, token, creator_id, target, is_file, mode, created_at, expires_at";
483static SHARE_BY_TOKEN: std::sync::LazyLock<String> =
484 std::sync::LazyLock::new(|| format!("SELECT {SHARE_COLS} FROM shares WHERE token = ?1"));
485static USER_SHARES: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
486 format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC")
487});
488
489fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
490 Ok(ShareRow {
491 id: r.get(0)?,
492 token: r.get(1)?,
493 creator_id: r.get(2)?,
494 target: r.get(3)?,
495 is_file: r.get::<_, i64>(4)? != 0,
496 mode: r.get(5)?,
497 created_at: r.get(6)?,
498 expires_at: r.get(7)?,
499 })
500}
501
502fn now() -> String {
503 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
504}
505
506const SCHEMA_V1: &str = r#"
507CREATE TABLE IF NOT EXISTS users (
508 id INTEGER PRIMARY KEY AUTOINCREMENT,
509 name TEXT NOT NULL UNIQUE COLLATE NOCASE,
510 pass_hash TEXT NOT NULL,
511 is_admin INTEGER NOT NULL DEFAULT 0,
512 created_at TEXT NOT NULL
513);
514
515CREATE TABLE IF NOT EXISTS user_roots (
516 id INTEGER PRIMARY KEY AUTOINCREMENT,
517 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
518 path TEXT NOT NULL,
519 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
520 UNIQUE (user_id, path)
521);
522
523CREATE TABLE IF NOT EXISTS shares (
524 id INTEGER PRIMARY KEY AUTOINCREMENT,
525 token TEXT NOT NULL UNIQUE,
526 creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
527 target TEXT NOT NULL,
528 is_file INTEGER NOT NULL,
529 mode TEXT NOT NULL CHECK (mode IN ('rw','ro')),
530 created_at TEXT NOT NULL,
531 expires_at TEXT
532);
533
534CREATE TABLE IF NOT EXISTS sessions (
535 token TEXT PRIMARY KEY,
536 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
537 created_at TEXT NOT NULL,
538 last_seen_at TEXT NOT NULL
539);
540
541CREATE TABLE IF NOT EXISTS settings (
542 key TEXT PRIMARY KEY,
543 value TEXT NOT NULL
544);
545INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_writable_shares', '0');
546"#;
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 // Most tests use an in-memory DB (the file-based path is still covered
553 // by `v1_db_migrates_to_v2` and the integration harness' `Db::open`).
554 async fn mem() -> Db {
555 Db::open_in_memory().await.unwrap()
556 }
557
558 async fn db_with_admin() -> (Db, User) {
559 let db = mem().await;
560 let hash = crate::auth::hash_password("admin1234").unwrap();
561 let admin = db.create_admin("admin", &hash).await.unwrap();
562 (db, admin)
563 }
564
565 #[tokio::test]
566 async fn fresh_db_state() {
567 let db = mem().await;
568 assert_eq!(db.user_count().await, 0);
569 assert_eq!(db.count_admins().await, 0);
570 assert!(!db.allow_writable_shares().await);
571 assert!(db.find_user_by_name("nobody").await.is_none());
572 assert!(db.find_user_by_id(1).await.is_none());
573 assert!(db.all_users().await.is_empty());
574 }
575
576 #[tokio::test]
577 async fn v1_db_migrates_to_v2() {
578 let dir = tempfile::tempdir().unwrap();
579 let path = dir.path().join("legacy.sqlite");
580 {
581 let conn = rusqlite::Connection::open(&path).unwrap();
582 conn.execute_batch(SCHEMA_V1).unwrap();
583 conn.execute(
584 "INSERT INTO users (name, pass_hash, is_admin, created_at)
585 VALUES ('legacy', 'hash', 1, '2024-01-01T00:00:00Z')",
586 [],
587 )
588 .unwrap();
589 conn.execute(
590 "INSERT INTO user_roots (user_id, path, mode) VALUES (1, 'docs', 'rw')",
591 [],
592 )
593 .unwrap();
594 }
595 let db = Db::open(&path).await.unwrap();
596 assert_eq!(db.user_count().await, 1);
597 let u = db.find_user_by_name("legacy").await.unwrap();
598 assert!(u.active, "v2 migration must default active to true");
599 assert!(u.is_admin);
600 assert_eq!(db.user_roots(u.id).await.len(), 1);
601 // Migrations are idempotent.
602 let db2 = Db::open(&path).await.unwrap();
603 assert_eq!(db2.user_count().await, 1);
604 assert!(db2.find_user_by_name("legacy").await.unwrap().active);
605 }
606
607 #[tokio::test]
608 async fn admin_user_and_passwords() {
609 let (db, admin) = db_with_admin().await;
610 assert!(admin.is_admin);
611 assert!(admin.active);
612 // Root "." rw is assigned by create_admin.
613 let roots = db.user_roots(admin.id).await;
614 assert_eq!(roots.len(), 1);
615 assert_eq!(roots[0].path, ".");
616 assert_eq!(roots[0].mode, "rw");
617
618 assert!(db.verify_password("admin", "admin1234").await.is_some());
619 assert!(db.verify_password("admin", "nope").await.is_none());
620 // Name lookup is case-insensitive (COLLATE NOCASE).
621 assert!(db.verify_password("ADMIN", "admin1234").await.is_some());
622 // Disabled users cannot verify.
623 db.set_user_active(admin.id, false).await.unwrap();
624 assert!(db.verify_password("admin", "admin1234").await.is_none());
625 db.set_user_active(admin.id, true).await.unwrap();
626 assert!(db.verify_password("admin", "admin1234").await.is_some());
627 }
628
629 #[tokio::test]
630 async fn sessions_lifecycle() {
631 let (db, admin) = db_with_admin().await;
632 assert!(db.session_user("ghost-token").await.is_none());
633 db.create_session(admin.id, "tok1").await.unwrap();
634 let u = db.session_user("tok1").await.unwrap();
635 assert_eq!(u.id, admin.id);
636 // Disabling the user invalidates existing sessions.
637 db.set_user_active(admin.id, false).await.unwrap();
638 assert!(db.session_user("tok1").await.is_none());
639 db.set_user_active(admin.id, true).await.unwrap();
640 assert!(db.session_user("tok1").await.is_some());
641 db.delete_session("tok1").await.unwrap();
642 assert!(db.session_user("tok1").await.is_none());
643 }
644
645 #[tokio::test]
646 async fn user_crud_and_roots() {
647 let (db, _admin) = db_with_admin().await;
648 let h = crate::auth::hash_password("bobpass1").unwrap();
649 let bob = db
650 .create_user("bob", &h, false, &[("docs".into(), "rw".into())])
651 .await
652 .unwrap();
653 assert!(!bob.is_admin);
654 assert!(bob.active);
655
656 // Duplicate name (case-insensitive) is rejected.
657 let h2 = crate::auth::hash_password("carolpass1").unwrap();
658 assert!(db.create_user("BOB", &h2, false, &[]).await.is_err());
659 assert!(db.create_user("carol", &h2, false, &[]).await.is_ok());
660
661 // Lookup helpers.
662 assert_eq!(db.find_user_by_name("Bob").await.unwrap().id, bob.id);
663 assert_eq!(db.find_user_by_id(bob.id).await.unwrap().name, "bob");
664 assert!(db.find_user_by_name("dave").await.is_none());
665 assert_eq!(db.all_users().await.len(), 3);
666
667 // Root replacement semantics.
668 let roots = db.user_roots(bob.id).await;
669 assert_eq!(roots.len(), 1);
670 db.set_user_roots(
671 bob.id,
672 &[(".".into(), "ro".into()), ("docs".into(), "rw".into())],
673 )
674 .await
675 .unwrap();
676 let roots = db.user_roots(bob.id).await;
677 assert_eq!(roots.len(), 2);
678 assert!(roots.iter().any(|r| r.path == "." && r.mode == "ro"));
679 db.set_user_roots(bob.id, &[]).await.unwrap();
680 assert!(db.user_roots(bob.id).await.is_empty());
681
682 // Password update.
683 let new_h = crate::auth::hash_password("bobpass2").unwrap();
684 db.update_user_password(bob.id, &new_h).await.unwrap();
685 assert!(db.verify_password("bob", "bobpass1").await.is_none());
686 assert!(db.verify_password("bob", "bobpass2").await.is_some());
687
688 // Admin flag + count (only active admins count).
689 db.set_user_admin(bob.id, true).await.unwrap();
690 assert_eq!(db.count_admins().await, 2);
691 db.set_user_active(bob.id, false).await.unwrap();
692 assert_eq!(db.count_admins().await, 1);
693 db.set_user_admin(bob.id, false).await.unwrap();
694
695 // Deletion.
696 assert!(db.delete_user(bob.id).await);
697 assert!(db.find_user_by_id(bob.id).await.is_none());
698 assert!(!db.delete_user(bob.id).await);
699 assert_eq!(db.user_count().await, 2);
700 }
701
702 fn share_row(expires_at: Option<&str>) -> ShareRow {
703 ShareRow {
704 id: 1,
705 token: "t".into(),
706 creator_id: 1,
707 target: "docs".into(),
708 is_file: false,
709 mode: "ro".into(),
710 created_at: "2024-01-01T00:00:00Z".into(),
711 expires_at: expires_at.map(str::to_string),
712 }
713 }
714
715 #[test]
716 fn share_expiry_logic() {
717 assert!(!share_row(None).is_expired());
718 assert!(!share_row(Some("2999-01-01T00:00:00Z")).is_expired());
719 assert!(share_row(Some("2000-01-01T00:00:00Z")).is_expired());
720 // Unparseable expiry → treated as not expired (fail open for reads).
721 assert!(!share_row(Some("not-a-date")).is_expired());
722 }
723
724 #[tokio::test]
725 async fn shares_crud() {
726 let (db, admin) = db_with_admin().await;
727 let s1 = db
728 .create_share(admin.id, "tok-a", "docs", false, "ro", None)
729 .await
730 .unwrap();
731 let s2 = db
732 .create_share(
733 admin.id,
734 "tok-b",
735 "file.txt",
736 true,
737 "rw",
738 Some("2999-01-01T00:00:00Z"),
739 )
740 .await
741 .unwrap();
742 assert!(s2.id > s1.id);
743
744 let found = db.share_by_token("tok-b").await.unwrap();
745 assert!(found.is_file);
746 assert_eq!(found.mode, "rw");
747 assert!(db.share_by_token("nope").await.is_none());
748
749 // Listed newest-first.
750 let list = db.user_shares(admin.id).await;
751 assert_eq!(list.len(), 2);
752 assert_eq!(list[0].id, s2.id);
753 // Other users see nothing.
754 let h = crate::auth::hash_password("bobpass1").unwrap();
755 let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
756 assert!(db.user_shares(bob.id).await.is_empty());
757
758 // Only the creator can delete.
759 assert!(!db.delete_share(s1.id, bob.id).await);
760 assert!(db.delete_share(s1.id, admin.id).await);
761 assert!(db.share_by_token("tok-a").await.is_none());
762 assert!(!db.delete_share(s1.id, admin.id).await);
763 }
764
765 #[tokio::test]
766 async fn settings_round_trip() {
767 let (db, _admin) = db_with_admin().await;
768 assert!(!db.allow_writable_shares().await);
769 db.set_allow_writable_shares(true).await.unwrap();
770 assert!(db.allow_writable_shares().await);
771 // Upsert semantics.
772 db.set_allow_writable_shares(false).await.unwrap();
773 assert!(!db.allow_writable_shares().await);
774 // Generic get/set.
775 db.set_setting("custom", "v").await.unwrap();
776 assert_eq!(db.get_setting("custom").await.as_deref(), Some("v"));
777 assert_eq!(db.get_setting("missing").await, None);
778 }
779}
780