@ -509,6 +509,97 @@ impl QuoteRepository for NativeRepository {
. await
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
}
}
/// Replace the admin auth code if `current` matches the stored value.
///
/// Generates a fresh 4-word passphrase when `new_code` is `None`.
/// Returns `Err(DbError::Forbidden)` if `current` does not match the stored code.
async fn update_admin_auth_code (
& self ,
current : & str ,
new_code : Option < & str > ,
) -> Result < String , DbError > {
let stored = self . get_admin_auth_code ( ) . await ? ;
if stored . as_deref ( ) ! = Some ( current ) {
return Err ( DbError ::Forbidden ) ;
}
let replacement = new_code
. map ( | s | s . to_owned ( ) )
. unwrap_or_else ( generate_auth_code ) ;
let replacement2 = replacement . clone ( ) ;
self . conn
. call ( move | conn | {
conn . execute (
" INSERT INTO admin_config ( key , value ) VALUES ( ' admin_auth_code ' , ? 1 ) \
ON CONFLICT ( key ) DO UPDATE SET value = excluded . value " ,
rusqlite ::params ! [ replacement2 ] ,
) ? ;
Ok ( ( ) )
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) ) ? ;
Ok ( replacement )
}
/// Return whether submissions are currently locked.
///
/// Reads the `submissions_locked` key from `admin_config`.
/// Returns `false` if the key has not been seeded yet.
async fn get_submissions_locked ( & self ) -> Result < bool , DbError > {
self . conn
. call ( | conn | {
let result : Option < String > = conn
. query_row (
"SELECT value FROM admin_config WHERE key = 'submissions_locked'" ,
[ ] ,
| row | row . get ( 0 ) ,
)
. optional ( ) ? ;
Ok ( result )
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
. map ( | opt | opt . as_deref ( ) = = Some ( "1" ) )
}
/// Persist the submissions lock state.
///
/// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked`
/// key in `admin_config`.
async fn set_submissions_locked ( & self , locked : bool ) -> Result < ( ) , DbError > {
let value = if locked { "1" } else { "0" } ;
self . conn
. call ( move | conn | {
conn . execute (
" INSERT INTO admin_config ( key , value ) VALUES ( ' submissions_locked ' , ? 1 ) \
ON CONFLICT ( key ) DO UPDATE SET value = excluded . value " ,
rusqlite ::params ! [ value ] ,
) ? ;
Ok ( ( ) )
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
}
/// Seed the `submissions_locked` key as `"0"` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_submissions_locked ( & self ) -> Result < ( ) , DbError > {
self . conn
. call ( | conn | {
conn . execute (
" INSERT OR IGNORE INTO admin_config ( key , value ) \
VALUES ( ' submissions_locked ' , '0' ) " ,
[ ] ,
) ? ;
Ok ( ( ) )
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
}
}
}
#[ cfg(test) ]
#[ cfg(test) ]
@ -791,4 +882,88 @@ mod tests {
let result = repo . delete_quote ( "nonexistent" , "any" ) . await . unwrap ( ) ;
let result = repo . delete_quote ( "nonexistent" , "any" ) . await . unwrap ( ) ;
assert_eq! ( result , DeleteResult ::NotFound ) ;
assert_eq! ( result , DeleteResult ::NotFound ) ;
}
}
// ── submissions_locked tests ───────────────────────────────────────────────
#[ tokio::test ]
async fn test_get_submissions_locked_default_false ( ) {
// A freshly migrated repo has no 'submissions_locked' key — must return false.
let repo = in_memory_repo ( ) . await ;
let locked = repo . get_submissions_locked ( ) . await . unwrap ( ) ;
assert! ( ! locked , "submissions_locked should default to false" ) ;
}
#[ tokio::test ]
async fn test_set_submissions_locked_true_then_get ( ) {
let repo = in_memory_repo ( ) . await ;
repo . set_submissions_locked ( true ) . await . unwrap ( ) ;
let locked = repo . get_submissions_locked ( ) . await . unwrap ( ) ;
assert! ( locked , "submissions_locked should be true after set" ) ;
}
#[ tokio::test ]
async fn test_seed_submissions_locked_does_not_overwrite ( ) {
// Set to true first, then seed — should remain true.
let repo = in_memory_repo ( ) . await ;
repo . set_submissions_locked ( true ) . await . unwrap ( ) ;
repo . seed_submissions_locked ( ) . await . unwrap ( ) ;
let locked = repo . get_submissions_locked ( ) . await . unwrap ( ) ;
assert! (
locked ,
"seed_submissions_locked must not overwrite an existing value"
) ;
}
// ── update_admin_auth_code tests ──────────────────────────────────────────
#[ tokio::test ]
async fn test_update_admin_auth_code_correct_current_succeeds ( ) {
let repo = in_memory_repo ( ) . await ;
repo . seed_admin_auth_code ( "initial-code-here" )
. await
. unwrap ( ) ;
let new_code = repo
. update_admin_auth_code ( "initial-code-here" , Some ( "brand-new-code" ) )
. await
. unwrap ( ) ;
assert_eq! ( new_code , "brand-new-code" ) ;
// Confirm the stored code was actually updated.
let stored = repo . get_admin_auth_code ( ) . await . unwrap ( ) ;
assert_eq! ( stored . as_deref ( ) , Some ( "brand-new-code" ) ) ;
}
#[ tokio::test ]
async fn test_update_admin_auth_code_generates_passphrase_when_none ( ) {
let repo = in_memory_repo ( ) . await ;
repo . seed_admin_auth_code ( "old-code" ) . await . unwrap ( ) ;
let new_code = repo . update_admin_auth_code ( "old-code" , None ) . await . unwrap ( ) ;
// The generated passphrase should be non-empty and different from the old one.
assert! ( ! new_code . is_empty ( ) ) ;
assert_ne! ( new_code , "old-code" ) ;
let stored = repo . get_admin_auth_code ( ) . await . unwrap ( ) ;
assert_eq! ( stored . as_deref ( ) , Some ( new_code . as_str ( ) ) ) ;
}
#[ tokio::test ]
async fn test_update_admin_auth_code_wrong_current_returns_forbidden ( ) {
let repo = in_memory_repo ( ) . await ;
repo . seed_admin_auth_code ( "correct-code" ) . await . unwrap ( ) ;
let result = repo
. update_admin_auth_code ( "wrong-code" , Some ( "new-code" ) )
. await ;
assert! (
matches! ( result , Err ( DbError ::Forbidden ) ) ,
"expected Forbidden, got {result:?}"
) ;
// Stored code must be unchanged.
let stored = repo . get_admin_auth_code ( ) . await . unwrap ( ) ;
assert_eq! ( stored . as_deref ( ) , Some ( "correct-code" ) ) ;
}
}
}