1use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use rusqlite::Connection;
13
14#[derive(Clone)]
20pub struct DbPool {
21 conn: Arc<Mutex<Connection>>,
22}
23
24impl DbPool {
25 pub fn open(db_path: &Path) -> Result<Self, rusqlite::Error> {
31 if let Some(parent) = db_path.parent() {
32 std::fs::create_dir_all(parent).ok();
33 }
34
35 let conn = Connection::open(db_path)?;
36
37 conn.execute_batch("PRAGMA journal_mode=WAL;")?;
38 conn.execute_batch("PRAGMA foreign_keys=ON;")?;
39 conn.execute_batch("PRAGMA busy_timeout=5000;")?;
40
41 let pool = Self {
42 conn: Arc::new(Mutex::new(conn)),
43 };
44
45 pool.run_migrations()?;
46
47 Ok(pool)
48 }
49
50 pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
56 let conn = Connection::open_in_memory()?;
57 conn.execute_batch("PRAGMA foreign_keys=ON;")?;
58
59 let pool = Self {
60 conn: Arc::new(Mutex::new(conn)),
61 };
62 pool.run_migrations()?;
63 Ok(pool)
64 }
65
66 pub fn with_conn<F, T>(&self, f: F) -> Result<T, rusqlite::Error>
73 where
74 F: FnOnce(&Connection) -> Result<T, rusqlite::Error>,
75 {
76 let conn = self
77 .conn
78 .lock()
79 .unwrap_or_else(std::sync::PoisonError::into_inner);
80 let result = f(&conn);
81 drop(conn);
82 result
83 }
84
85 fn run_migrations(&self) -> Result<(), rusqlite::Error> {
87 let conn = self
88 .conn
89 .lock()
90 .unwrap_or_else(std::sync::PoisonError::into_inner);
91 conn.execute_batch(INITIAL_MIGRATION)?;
92 drop(conn);
93 Ok(())
94 }
95}
96
97const INITIAL_MIGRATION: &str = r"
99-- Users
100CREATE TABLE IF NOT EXISTS users (
101 id INTEGER PRIMARY KEY AUTOINCREMENT,
102 uuid TEXT NOT NULL UNIQUE,
103 login TEXT NOT NULL UNIQUE,
104 name TEXT NOT NULL,
105 email TEXT NOT NULL UNIQUE,
106 pwdhash TEXT NOT NULL,
107 apikey TEXT NOT NULL,
108 is_active INTEGER NOT NULL DEFAULT 1,
109 is_admin INTEGER NOT NULL DEFAULT 0,
110 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
111 last_seen TEXT
112);
113CREATE INDEX IF NOT EXISTS idx_users_uuid ON users (uuid);
114CREATE INDEX IF NOT EXISTS idx_users_apikey ON users (apikey);
115CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
116
117-- Sessions
118CREATE TABLE IF NOT EXISTS sessions (
119 id TEXT PRIMARY KEY,
120 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
121 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
122 expires_at TEXT NOT NULL
123);
124CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
125
126-- Audit log (ISO 8601 timestamps)
127CREATE TABLE IF NOT EXISTS audit_log (
128 id INTEGER PRIMARY KEY AUTOINCREMENT,
129 timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')),
130 action TEXT NOT NULL,
131 tracking_id TEXT NOT NULL,
132 user_id INTEGER REFERENCES users(id),
133 details TEXT,
134 CONSTRAINT valid_action CHECK (action IN ('create', 'update', 'delete', 'import', 'export', 'settings_reset'))
135);
136CREATE INDEX IF NOT EXISTS idx_audit_log_tracking ON audit_log(tracking_id);
137CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
138";
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn test_open_in_memory() {
146 let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
147 pool.with_conn(|conn| {
148 conn.execute(
149 "INSERT INTO users (uuid, login, name, email, pwdhash, apikey) \
150 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
151 rusqlite::params![
152 "test-uuid",
153 "admin",
154 "Admin",
155 "admin@test.com",
156 "hash",
157 "key"
158 ],
159 )?;
160 let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
161 assert_eq!(count, 1);
162 Ok(())
163 })
164 .expect("DB operation failed");
165 }
166
167 #[test]
168 fn test_audit_log_constraint() {
169 let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
170 let result = pool.with_conn(|conn| {
171 conn.execute(
172 "INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
173 rusqlite::params!["invalid_action", "test-id"],
174 )?;
175 Ok(())
176 });
177 assert!(result.is_err(), "Invalid action should fail constraint");
178 }
179
180 #[test]
181 fn test_audit_log_valid_actions() {
182 let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
183 for action in &["create", "update", "delete", "import", "export"] {
184 pool.with_conn(|conn| {
185 conn.execute(
186 "INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
187 rusqlite::params![action, format!("test-{action}")],
188 )?;
189 Ok(())
190 })
191 .unwrap_or_else(|e| panic!("Action '{action}' should be valid: {e}"));
192 }
193 }
194
195 #[test]
196 fn test_foreign_key_enforcement() {
197 let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
198 let result = pool.with_conn(|conn| {
199 conn.execute(
200 "INSERT INTO sessions (id, user_id, expires_at) VALUES (?1, ?2, ?3)",
201 rusqlite::params!["session-1", 999, "2026-12-31T00:00:00Z"],
202 )?;
203 Ok(())
204 });
205 assert!(result.is_err(), "FK to nonexistent user should fail");
206 }
207}