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