diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index c64703a..65dc6f3 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -565,4 +565,90 @@ impl QuoteRepository for D1Repository { .map(|_| ()) .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 { + 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); + + self.db + .prepare( + "INSERT INTO admin_config (key, value) VALUES ('admin_auth_code', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .bind(&[JsValue::from_str(&replacement)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .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 { + #[derive(serde::Deserialize)] + struct ValueRow { + value: String, + } + + let row = self + .db + .prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'") + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + Ok(row.map(|r| r.value == "1").unwrap_or(false)) + } + + /// 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.db + .prepare( + "INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .bind(&[JsValue::from_str(value)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .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.db + .prepare( + "INSERT OR IGNORE INTO admin_config (key, value) \ + VALUES ('submissions_locked', '0')", + ) + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } } diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index baf91f4..86e5295 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -151,4 +151,33 @@ pub trait QuoteRepository { /// /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>; + + /// Replace the admin auth code if `current` matches the stored value. + /// + /// If `new_code` is `None`, a fresh 4-word passphrase is auto-generated. + /// Returns the new auth code on success. + /// Returns `Err(DbError::Forbidden)` if `current` does not match. + async fn update_admin_auth_code( + &self, + current: &str, + new_code: Option<&str>, + ) -> Result; + + /// Return whether submissions are currently locked. + /// + /// Reads the `submissions_locked` key from `admin_config`. + /// Returns `false` if the key has not been seeded yet (defaults to open). + async fn get_submissions_locked(&self) -> Result; + + /// Persist the submissions lock state. + /// + /// Writes `"1"` (locked) or `"0"` (unlocked) to the `submissions_locked` + /// key in `admin_config`, upserting if necessary. + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>; + + /// Seed the `submissions_locked` key as `"0"` if not already present. + /// + /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. + /// Call once on startup to ensure the key exists. + async fn seed_submissions_locked(&self) -> Result<(), DbError>; } diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index d3d12b1..14b0008 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -509,6 +509,97 @@ impl QuoteRepository for NativeRepository { .await .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 { + 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 { + self.conn + .call(|conn| { + let result: Option = 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)] @@ -791,4 +882,88 @@ mod tests { let result = repo.delete_quote("nonexistent", "any").await.unwrap(); 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")); + } } diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 0685dc6..ee236ac 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -550,6 +550,26 @@ mod tests { async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> { Ok(()) } + + async fn update_admin_auth_code( + &self, + _current: &str, + _new_code: Option<&str>, + ) -> Result { + Err(DbError::Forbidden) + } + + async fn get_submissions_locked(&self) -> Result { + Ok(false) + } + + async fn set_submissions_locked(&self, _locked: bool) -> Result<(), DbError> { + Ok(()) + } + + async fn seed_submissions_locked(&self) -> Result<(), DbError> { + Ok(()) + } } fn sample_quote() -> Quote { diff --git a/quotesdb/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index e9fee31..413064d 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -43,6 +43,13 @@ async fn main() { .await .expect("failed to seed admin code"); } + + // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op + // if the key already exists, so this never overwrites an active lock). + repo.seed_submissions_locked() + .await + .expect("failed to seed submissions lock"); + let admin_code = repo .get_admin_auth_code() .await @@ -112,6 +119,12 @@ pub async fn fetch( .map_err(|e| worker::Error::RustError(e.to_string()))?; } + // Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op + // if the key already exists, so this never overwrites an active lock). + repo.seed_submissions_locked() + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + // Wrap in Arc so it can be shared across handlers via Axum state. // D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32. let repo: Arc = Arc::new(repo);