1use sqlx::Row;
2use sqlx::sqlite::SqliteRow;
3use tracing::{info, warn};
4use uuid::Uuid;
5
6use crate::sqlite::db::Database;
7use crate::sqlite::nonce::now_secs;
8use crate::sqlite::order::rfc3339;
9
10#[derive(Debug, Clone)]
26pub struct AdminRecoveryCode {
27 pub id: Uuid,
28 pub user_id: Uuid,
29 pub code_hash: String,
31 pub created_at: i64,
32 pub used_at: Option<i64>,
34}
35
36impl AdminRecoveryCode {
37 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
38 Ok(AdminRecoveryCode {
39 id: row.try_get("id")?,
40 user_id: row.try_get("user_id")?,
41 code_hash: row.try_get("code_hash")?,
42 created_at: row.try_get("created_at")?,
43 used_at: row.try_get("used_at")?,
44 })
45 }
46
47 pub async fn replace_all(
58 user_id: Uuid,
59 hashes: &[String],
60 database: &Database,
61 ) -> Result<(), sqlx::Error> {
62 let now = now_secs();
63 let mut tx = database.pool.begin().await?;
64
65 sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
66 .bind(user_id)
67 .execute(&mut *tx)
68 .await?;
69
70 for hash in hashes {
71 sqlx::query(
72 "INSERT INTO admin_recovery_codes (id, user_id, code_hash, created_at, used_at) \
73 VALUES (?, ?, ?, ?, NULL);",
74 )
75 .bind(crate::sqlite::id::mint())
76 .bind(user_id)
77 .bind(hash)
78 .bind(now)
79 .execute(&mut *tx)
80 .await?;
81 }
82
83 tx.commit().await?;
84
85 info!(
86 event = "db_admin_recovery_codes_replaced",
87 outcome = "success",
88 user_id = %user_id,
89 minted = hashes.len()
90 );
91 Ok(())
92 }
93
94 pub async fn list_unused(
97 user_id: Uuid,
98 database: &Database,
99 ) -> Result<Vec<AdminRecoveryCode>, sqlx::Error> {
100 let rows = sqlx::query(
101 "SELECT id, user_id, code_hash, created_at, used_at \
102 FROM admin_recovery_codes WHERE user_id = ? AND used_at IS NULL \
103 ORDER BY created_at ASC, id ASC;",
104 )
105 .bind(user_id)
106 .fetch_all(&database.pool)
107 .await?;
108
109 rows.into_iter().map(AdminRecoveryCode::from_row).collect()
110 }
111
112 pub async fn count_unused(user_id: Uuid, database: &Database) -> Result<i64, sqlx::Error> {
115 let row = sqlx::query(
116 "SELECT COUNT(*) AS total FROM admin_recovery_codes \
117 WHERE user_id = ? AND used_at IS NULL;",
118 )
119 .bind(user_id)
120 .fetch_one(&database.pool)
121 .await?;
122
123 row.try_get("total")
124 }
125
126 pub async fn consume(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
136 let result = sqlx::query(
137 "UPDATE admin_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL;",
138 )
139 .bind(now_secs())
140 .bind(id)
141 .execute(&database.pool)
142 .await?;
143
144 let consumed = result.rows_affected() == 1;
145 if !consumed {
146 warn!(event = "db_admin_recovery_code_already_used", outcome = "failure", code_id = %id);
150 }
151 Ok(consumed)
152 }
153
154 pub async fn delete_for_user(user_id: Uuid, database: &Database) -> Result<u64, sqlx::Error> {
158 let result = sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
159 .bind(user_id)
160 .execute(&database.pool)
161 .await?;
162
163 Ok(result.rows_affected())
164 }
165
166 #[must_use]
169 pub fn to_json(&self) -> serde_json::Value {
170 serde_json::json!({
171 "id": self.id,
172 "createdAt": rfc3339(self.created_at),
173 "usedAt": self.used_at.map(rfc3339),
174 })
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::sqlite::admin_user::AdminUser;
182 use std::sync::Arc;
183
184 async fn db_with_user() -> (Arc<Database>, AdminUser) {
185 let db = Arc::new(Database::connect_in_memory().await.unwrap());
186 let user = AdminUser::create("alice", "hash", &db).await.unwrap();
187 (db, user)
188 }
189
190 fn hashes(count: usize) -> Vec<String> {
191 (0..count).map(|index| format!("hash-{index}")).collect()
192 }
193
194 #[tokio::test]
195 async fn replace_all_mints_a_set_and_supersedes_the_previous_one() {
196 let (db, user) = db_with_user().await;
197
198 AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
199 .await
200 .unwrap();
201 assert_eq!(
202 AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
203 10
204 );
205
206 let first = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].id;
210 assert!(AdminRecoveryCode::consume(first, &db).await.unwrap());
211 assert_eq!(
212 AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
213 9
214 );
215
216 AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
217 .await
218 .unwrap();
219 let after = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap();
220 assert_eq!(after.len(), 10);
221 assert!(
222 after.iter().all(|code| code.id != first),
223 "no row of the superseded set may survive"
224 );
225 }
226
227 #[tokio::test]
230 async fn a_code_can_be_consumed_exactly_once() {
231 let (db, user) = db_with_user().await;
232 AdminRecoveryCode::replace_all(user.id, &hashes(3), &db)
233 .await
234 .unwrap();
235
236 let code = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].clone();
237
238 assert!(AdminRecoveryCode::consume(code.id, &db).await.unwrap());
239 assert!(
240 !AdminRecoveryCode::consume(code.id, &db).await.unwrap(),
241 "a second consumption of one code must fail, whatever raced it"
242 );
243 assert!(
244 !AdminRecoveryCode::consume(crate::sqlite::id::mint(), &db)
245 .await
246 .unwrap()
247 );
248
249 let remaining = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap();
251 assert_eq!(remaining.len(), 2);
252 assert!(remaining.iter().all(|other| other.id != code.id));
253 }
254
255 #[tokio::test]
256 async fn deleting_the_operator_cascades_to_their_codes() {
257 let (db, user) = db_with_user().await;
258 AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
259 .await
260 .unwrap();
261
262 assert!(AdminUser::delete(user.id, &db).await.unwrap());
263 assert_eq!(
264 AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
265 0
266 );
267 }
268
269 #[tokio::test]
270 async fn delete_for_user_removes_the_whole_set() {
271 let (db, user) = db_with_user().await;
272 AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
273 .await
274 .unwrap();
275
276 assert_eq!(
277 AdminRecoveryCode::delete_for_user(user.id, &db)
278 .await
279 .unwrap(),
280 10
281 );
282 assert_eq!(
283 AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
284 0
285 );
286 }
287
288 #[tokio::test]
289 async fn to_json_leaks_no_hash() {
290 let (db, user) = db_with_user().await;
291 AdminRecoveryCode::replace_all(user.id, &["a-secret-hash".to_string()], &db)
292 .await
293 .unwrap();
294
295 let code = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].clone();
296 let rendered = code.to_json().to_string();
297
298 assert!(!rendered.contains("a-secret-hash"));
299 assert!(!rendered.contains("codeHash"));
300 assert!(rendered.contains(&code.id.to_string()));
301 assert!(rendered.contains("\"usedAt\":null"));
302 }
303}