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