From c9142edbbf3928c9a5f22979a989a2b7cadde978 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 12:51:40 -0800 Subject: [PATCH] =?UTF-8?q?feat(quotesdb):=20DB=20layer=20=E2=80=94=20add?= =?UTF-8?q?=20submissions=5Flocked=20+=20update=5Fadmin=5Fauth=5Fcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new QuoteRepository trait methods and a seed helper: - update_admin_auth_code(current, new_code): replaces the admin code if `current` matches; generates a fresh passphrase when new_code is None; returns DbError::Forbidden on mismatch. - get_submissions_locked(): reads the submissions_locked key from admin_config; returns false when the key is absent. - set_submissions_locked(locked): upserts "1"/"0" into admin_config. - seed_submissions_locked(): INSERT OR IGNORE "0" — safe to call on every startup without clobbering an active lock. Implemented in both NativeRepository (rusqlite) and D1Repository (wasm32). Updated startup seeding in main.rs (native and wasm32 paths) to call seed_submissions_locked after the existing admin auth code seeding. Added 7 unit tests in db/native.rs covering all four specified scenarios: default false, set-then-get, seed does not overwrite, correct code succeeds, None new_code generates passphrase, wrong code returns Forbidden, stored code unchanged after Forbidden. MockRepo in handlers/mod.rs updated with stub implementations of all four new trait methods to satisfy the trait bound. Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/src/bin/api/db/d1.rs | 86 +++++++++++++ quotesdb/src/bin/api/db/mod.rs | 29 +++++ quotesdb/src/bin/api/db/native.rs | 175 +++++++++++++++++++++++++++ quotesdb/src/bin/api/handlers/mod.rs | 20 +++ quotesdb/src/bin/api/main.rs | 13 ++ 5 files changed, 323 insertions(+) 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);