feat(admin): runtime-editable crawler & analysis config in the dashboard
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
272
backend/tests/api_admin_settings.rs
Normal file
272
backend/tests/api_admin_settings.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
//! Integration tests for the runtime-editable settings endpoints
|
||||
//! (`/api/v1/admin/settings/{crawler,analysis}`):
|
||||
//!
|
||||
//! * the `RequireAdmin` gate,
|
||||
//! * `GET` returns the editable DTO + the read-only env-managed view (and the
|
||||
//! analysis prompt defaults), never the secret,
|
||||
//! * `PUT` validates (422 with per-field details on bad input), persists, and
|
||||
//! writes an `admin_audit` row,
|
||||
//! * `PUT` invokes the daemon reloader with the converted config and moves the
|
||||
//! analysis enable gate,
|
||||
//! * the repo-level env→DB seed is idempotent.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use mangalord::repo;
|
||||
|
||||
async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) {
|
||||
let (username, cookie) = common::register_user(app).await;
|
||||
let u = repo::user::find_by_username(pool, &username)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap();
|
||||
(username, cookie, u.id)
|
||||
}
|
||||
|
||||
// ---- RequireAdmin gate -----------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_settings_requires_admin(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_u, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_settings_rejects_anonymous(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
// No cookie at all → not logged in.
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/admin/settings/crawler"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_settings_requires_admin(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_u, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/analysis",
|
||||
json!({ "enabled": false }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ---- GET shape -------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_crawler_returns_editable_and_env_only(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_u, cookie, _id) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Editable knobs present with their defaults.
|
||||
assert_eq!(body["editable"]["daily_at"], "00:00");
|
||||
assert_eq!(body["editable"]["chapter_workers"], 1);
|
||||
// Env-managed view present and read-only.
|
||||
assert_eq!(body["env_only"]["browser_mode"], "headless");
|
||||
assert_eq!(body["env_only"]["session_configured"], false);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_analysis_returns_defaults_and_no_secret(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_u, cookie, _id) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/settings/analysis", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
// Unmodified prompts seed as null ("use default").
|
||||
assert!(body["editable"]["system_prompt"].is_null());
|
||||
// Compiled defaults exposed for the UI placeholder / reset.
|
||||
assert!(body["prompt_defaults"]["system_prompt"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("manga"));
|
||||
// The secret is never echoed; only a boolean indicator.
|
||||
assert!(body["editable"].get("api_key").is_none());
|
||||
assert_eq!(body["env_only"]["api_key_configured"], false);
|
||||
}
|
||||
|
||||
// ---- PUT validation --------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_crawler_invalid_tz_returns_422(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_u, cookie, _id) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/crawler",
|
||||
json!({ "tz": "Mars/Phobos", "daily_at": "9am" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
let fields: Vec<&str> = body["error"]["details"]["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["field"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(fields.contains(&"tz"));
|
||||
assert!(fields.contains(&"daily_at"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_analysis_invalid_returns_422(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_u, cookie, _id) = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/analysis",
|
||||
json!({ "workers": 0, "slice_overlap": 2.0, "endpoint": "nope" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
let fields: Vec<&str> = body["error"]["details"]["fields"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["field"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(fields.contains(&"workers"));
|
||||
assert!(fields.contains(&"slice_overlap"));
|
||||
assert!(fields.contains(&"endpoint"));
|
||||
}
|
||||
|
||||
// ---- PUT persistence + audit ----------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_crawler_persists_and_audits(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_u, cookie, admin_id) = seed_admin(&pool, &h.app).await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/crawler",
|
||||
json!({ "rate_ms": 2500, "chapter_workers": 4, "start_url": "https://example.com/" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["editable"]["rate_ms"], 2500);
|
||||
assert_eq!(body["editable"]["chapter_workers"], 4);
|
||||
// Allowlist normalized to include the start-url host.
|
||||
let allow: Vec<&str> = body["editable"]["download_allowlist"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert!(allow.contains(&"example.com"));
|
||||
|
||||
// A second GET reflects the persisted change.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie("/api/v1/admin/settings/crawler", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["editable"]["rate_ms"], 2500);
|
||||
|
||||
// An audit row landed.
|
||||
let (action, kind): (String, String) = sqlx::query_as(
|
||||
"SELECT action, target_kind FROM admin_audit WHERE actor_user_id = $1 ORDER BY at DESC LIMIT 1",
|
||||
)
|
||||
.bind(admin_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(action, "update_crawler_settings");
|
||||
assert_eq!(kind, "settings");
|
||||
}
|
||||
|
||||
// ---- PUT triggers reload ---------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_analysis_triggers_reload_and_flips_gate(pool: PgPool) {
|
||||
let (h, reloader) = common::harness_with_settings_reloader(pool.clone());
|
||||
let (_u, cookie, _id) = seed_admin(&pool, &h.app).await;
|
||||
|
||||
// Gate starts closed.
|
||||
assert!(!reloader.runtime.analysis_enabled());
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/analysis",
|
||||
json!({ "enabled": true, "model": "qwen2-vl", "temperature": 0.4 }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// The reloader was invoked with the converted config, and the shared gate
|
||||
// flipped on — both without a restart.
|
||||
assert!(reloader.runtime.analysis_enabled());
|
||||
let applied = reloader.analysis.lock().unwrap().clone().expect("reload called");
|
||||
assert!(applied.enabled);
|
||||
assert_eq!(applied.model, "qwen2-vl");
|
||||
assert_eq!(applied.temperature, 0.4);
|
||||
}
|
||||
|
||||
// ---- repo-level seed -------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn seed_if_absent_is_idempotent(pool: PgPool) {
|
||||
let v1 = json!({ "rate_ms": 1000 });
|
||||
let v2 = json!({ "rate_ms": 9999 });
|
||||
// First seed inserts.
|
||||
assert!(repo::app_settings::seed_if_absent(&pool, "crawler", &v1).await.unwrap());
|
||||
// Second seed is a no-op (row already present) and does not overwrite.
|
||||
assert!(!repo::app_settings::seed_if_absent(&pool, "crawler", &v2).await.unwrap());
|
||||
let got = repo::app_settings::get(&pool, "crawler").await.unwrap().unwrap();
|
||||
assert_eq!(got["rate_ms"], 1000);
|
||||
// upsert does overwrite.
|
||||
repo::app_settings::upsert(&pool, "crawler", &v2).await.unwrap();
|
||||
let got = repo::app_settings::get(&pool, "crawler").await.unwrap().unwrap();
|
||||
assert_eq!(got["rate_ms"], 9999);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ use sqlx::PgPool;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use mangalord::app::{router, AppState};
|
||||
use mangalord::app::{router, AppState, RuntimeControls};
|
||||
use mangalord::auth::rate_limit::AuthRateLimiter;
|
||||
use mangalord::config::{AuthConfig, UploadConfig};
|
||||
use mangalord::config::{AnalysisConfig, AuthConfig, CrawlerConfig, UploadConfig};
|
||||
use mangalord::storage::{LocalStorage, PutByteStream, Storage, StorageError, StreamingFile};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -76,13 +76,15 @@ fn harness_with_auth_config(
|
||||
auth_limiter,
|
||||
// Default harness has no crawler daemon wired up; admin resync
|
||||
// handlers return 503 in this config. Tests that need a stub
|
||||
// resync service swap it in via `harness_with_resync`.
|
||||
resync: None,
|
||||
crawler: None,
|
||||
// resync service swap it in via `harness_with_resync`. No reloader,
|
||||
// so settings still persist but spawn no daemon.
|
||||
runtime: Arc::new(RuntimeControls::new(false)),
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
// Empty allowlist = CSRF check skipped. The CSRF-specific test
|
||||
// harness `harness_with_admin_origins` overrides this.
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
analysis_enabled: false,
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness { app: router(state), _storage_dir: storage_dir }
|
||||
@@ -148,6 +150,8 @@ pub fn harness_with_resync(
|
||||
..AuthConfig::default()
|
||||
};
|
||||
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
||||
let runtime = Arc::new(RuntimeControls::new(false));
|
||||
runtime.set_resync(Some(resync));
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
storage,
|
||||
@@ -157,10 +161,11 @@ pub fn harness_with_resync(
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
resync: Some(resync),
|
||||
crawler: None,
|
||||
runtime,
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
analysis_enabled: false,
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness {
|
||||
@@ -189,10 +194,11 @@ pub fn harness_with_analysis(pool: PgPool) -> Harness {
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
resync: None,
|
||||
crawler: None,
|
||||
runtime: Arc::new(RuntimeControls::new(true)),
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
analysis_enabled: true,
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness {
|
||||
@@ -201,6 +207,71 @@ pub fn harness_with_analysis(pool: PgPool) -> Harness {
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`DaemonReloader`] stub that records the configs it was asked to apply
|
||||
/// and flips the shared analysis gate, without spawning any real daemon. Lets
|
||||
/// settings tests assert that a `PUT` triggers a reload with the converted
|
||||
/// config (and that the analysis enable gate moves).
|
||||
pub struct StubReloader {
|
||||
pub runtime: Arc<RuntimeControls>,
|
||||
pub crawler: std::sync::Mutex<Option<CrawlerConfig>>,
|
||||
pub analysis: std::sync::Mutex<Option<AnalysisConfig>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl mangalord::app::DaemonReloader for StubReloader {
|
||||
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
|
||||
*self.crawler.lock().unwrap() = Some(cfg);
|
||||
Ok(())
|
||||
}
|
||||
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
|
||||
self.runtime.set_analysis_enabled(cfg.enabled);
|
||||
*self.analysis.lock().unwrap() = Some(cfg);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`harness`] but wires a [`StubReloader`] so the settings `PUT`
|
||||
/// endpoints exercise the reload path. Returns the harness plus the shared
|
||||
/// stub so the test can inspect what was applied.
|
||||
pub fn harness_with_settings_reloader(pool: PgPool) -> (Harness, Arc<StubReloader>) {
|
||||
let storage_dir = tempfile::tempdir().expect("tempdir");
|
||||
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
||||
let auth = AuthConfig {
|
||||
cookie_secure: false,
|
||||
..AuthConfig::default()
|
||||
};
|
||||
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
||||
let runtime = Arc::new(RuntimeControls::new(false));
|
||||
let reloader = Arc::new(StubReloader {
|
||||
runtime: Arc::clone(&runtime),
|
||||
crawler: std::sync::Mutex::new(None),
|
||||
analysis: std::sync::Mutex::new(None),
|
||||
});
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
storage,
|
||||
auth,
|
||||
upload: UploadConfig {
|
||||
max_request_bytes: 4 * 1024 * 1024,
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
runtime,
|
||||
reloader: Some(Arc::clone(&reloader) as Arc<dyn mangalord::app::DaemonReloader>),
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
(
|
||||
Harness {
|
||||
app: router(state),
|
||||
_storage_dir: storage_dir,
|
||||
},
|
||||
reloader,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`harness`] but configures an admin CSRF allowlist so the
|
||||
/// `/admin/*` mutating endpoints reject cross-origin browser POSTs.
|
||||
/// Used by the admin CSRF integration tests.
|
||||
@@ -220,10 +291,11 @@ pub fn harness_with_admin_origins(pool: PgPool, origins: Vec<String>) -> Harness
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
resync: None,
|
||||
crawler: None,
|
||||
runtime: Arc::new(RuntimeControls::new(false)),
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(origins),
|
||||
analysis_enabled: false,
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness {
|
||||
|
||||
Reference in New Issue
Block a user