use std::sync::Arc;
use autumn_web::runtime_config::RuntimeConfigService;
use std::collections::HashMap;
use std::convert::Infallible;
use autumn_web::extract::Multipart;
use autumn_web::flash::{Flash, FlashLevel, FlashMessage};
use autumn_web::job::{JobAdminQuery, JobAdminSnapshot, JobScheduleSummary, job_admin_backend};
use autumn_web::prelude::{HxRequest, HxResponseExt};
use autumn_web::security::{CsrfFormField, CsrfToken, CsrfTokenHeader};
use autumn_web::{AppState, AutumnError, AutumnResult};
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::request::Parts;
use axum::http::{StatusCode, header};
use axum::middleware::from_fn;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing;
use diesel_async::pooled_connection::deadpool::Pool;
use futures::future::join_all;
use serde::Deserialize;
use serde_json::Value;
use crate::auth::{check_role, publish_current_actor};
use crate::impersonation::AdminImpersonation;
use crate::registry::AdminRegistry;
use crate::templates;
use crate::traits::{
AdminError, AdminField, AdminFieldKind, AdminImportError, AdminImportReport,
AdminImportRowResult, AdminModel, CsvImportMode, ListParams, SortDirection, record_id,
};
#[derive(Debug, Clone, Default)]
pub struct AdminCsrf {
token: String,
form_field: String,
token_header: String,
}
impl AdminCsrf {
#[must_use]
pub fn token(&self) -> &str {
&self.token
}
#[must_use]
pub fn form_field(&self) -> &str {
if self.form_field.is_empty() {
"_csrf"
} else {
&self.form_field
}
}
#[must_use]
pub fn token_header(&self) -> &str {
if self.token_header.is_empty() {
"X-CSRF-Token"
} else {
&self.token_header
}
}
}
impl<S: Send + Sync> FromRequestParts<S> for AdminCsrf {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts
.extensions
.get::<CsrfToken>()
.map(|t| t.token().to_owned())
.unwrap_or_default();
let form_field = parts
.extensions
.get::<CsrfFormField>()
.map_or_else(|| "_csrf".to_owned(), |field| field.0.clone());
let token_header = parts
.extensions
.get::<CsrfTokenHeader>()
.map_or_else(|| "X-CSRF-Token".to_owned(), |h| h.0.clone());
Ok(Self {
token,
form_field,
token_header,
})
}
}
pub static ASSETS: autumn_web::assets::PluginAssets = autumn_web::assets::PluginAssets::from_files(
"autumn-admin",
&[("admin.js", include_bytes!("admin.js"))],
);
#[allow(clippy::too_many_arguments)]
pub fn admin_router(
registry: Arc<AdminRegistry>,
prefix: &str,
actuator_prefix: String,
auth_session_key: String,
require_role: Option<String>,
config_svc: Option<Arc<RuntimeConfigService>>,
step_up_mutations: bool,
step_up_max_age_secs: u64,
impersonation_enabled: bool,
) -> axum::Router<AppState> {
let has_config = config_svc.is_some();
let actor_session_key = auth_session_key.clone();
let mut router = axum::Router::new()
.route("/", routing::get(dashboard))
.route("/jobs", routing::get(jobs_dashboard))
.route("/jobs/counters", routing::get(jobs_counters))
.route("/jobs/{id}/retry", routing::post(job_retry))
.route("/jobs/{id}/discard", routing::post(job_discard))
.route("/jobs/{id}/cancel", routing::post(job_cancel));
if let Some(svc) = config_svc {
router = router
.route("/config", routing::get(config_list))
.route("/config/{key}/set", routing::post(config_set))
.route("/config/{key}/unset", routing::post(config_unset))
.route("/config/{key}/history", routing::get(config_key_history))
.layer(axum::Extension(AdminAuthSessionKey(
auth_session_key.clone(),
)))
.layer(axum::Extension(svc));
}
if impersonation_enabled {
router = router.route(
"/impersonate",
routing::post(crate::impersonation::impersonate_begin),
);
}
router = router
.route("/{slug}", routing::get(model_list).post(model_create))
.route("/{slug}/new", routing::get(model_new_form))
.route(
"/{slug}/{id}",
routing::get(model_detail)
.post(model_update)
.delete(model_delete),
)
.route("/{slug}/{id}/history", routing::get(model_history))
.route("/{slug}/{id}/edit", routing::get(model_edit_form))
.route("/{slug}/actions", routing::post(model_action))
.route("/{slug}/export.csv", routing::get(model_export_csv))
.route(
"/{slug}/import",
routing::get(model_import_form).post(model_import_csv),
)
.layer(axum::Extension(HasRuntimeConfig(has_config)))
.layer(axum::Extension(AdminPrefix(prefix.to_owned())))
.layer(axum::Extension(ActuatorPrefix(actuator_prefix)))
.layer(axum::Extension(registry));
let router = if step_up_mutations {
router.layer(from_fn(move |req, next| {
crate::auth::check_step_up_mutations(step_up_max_age_secs, req, next)
}))
} else {
router
};
let router = match require_role {
Some(role) => router.layer(from_fn(move |req, next| {
check_role(role.clone(), auth_session_key.clone(), req, next)
})),
None => router,
};
if !impersonation_enabled {
return router.layer(from_fn(move |req, next| {
publish_current_actor(actor_session_key.clone(), req, next)
}));
}
router
.merge(
axum::Router::new()
.route(
"/impersonate/stop",
routing::post(crate::impersonation::impersonate_stop),
)
.layer(axum::Extension(AdminPrefix(prefix.to_owned()))),
)
.layer(from_fn(move |req, next| {
publish_current_actor(actor_session_key.clone(), req, next)
}))
}
#[derive(Clone)]
pub struct AdminPrefix(pub String);
#[derive(Clone)]
struct HasRuntimeConfig(bool);
#[derive(Clone)]
struct ActuatorPrefix(String);
#[derive(Clone)]
struct AdminAuthSessionKey(String);
#[derive(Debug, Deserialize, Default)]
struct ListQuery {
#[serde(default = "default_page")]
page: u64,
#[serde(default)]
q: String,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
dir: SortDirection,
}
#[derive(Debug, Deserialize)]
struct JobsQuery {
#[serde(default = "default_page", rename = "enqueued_page")]
enqueued: u64,
#[serde(default = "default_page", rename = "scheduled_page")]
scheduled: u64,
#[serde(default = "default_page", rename = "running_page")]
running: u64,
#[serde(default = "default_page", rename = "completed_page")]
completed: u64,
#[serde(default = "default_page", rename = "failed_page")]
failed: u64,
#[serde(default = "default_jobs_per_page", rename = "per_page")]
per: u64,
}
impl From<JobsQuery> for JobAdminQuery {
fn from(query: JobsQuery) -> Self {
Self {
enqueued_page: query.enqueued.max(1),
scheduled_page: query.scheduled.max(1),
running_page: query.running.max(1),
completed_page: query.completed.max(1),
failed_page: query.failed.max(1),
per_page: query.per.clamp(1, 100),
}
}
}
const fn default_page() -> u64 {
1
}
const fn default_jobs_per_page() -> u64 {
25
}
fn resolve<'r>(
state: &AppState,
registry: &'r AdminRegistry,
slug: &str,
) -> AutumnResult<(Pool<::autumn_web::RuntimeConnection>, &'r dyn AdminModel)> {
let pool = state
.pool()
.cloned()
.ok_or_else(|| AutumnError::service_unavailable_msg("No database pool configured"))?;
let model = registry
.get(slug)
.ok_or_else(|| AutumnError::not_found_msg(format!("Model '{slug}' not found")))?;
Ok((pool, model))
}
fn validate_sort_key(sort: Option<String>, fields: &[AdminField]) -> Option<String> {
sort.filter(|s| {
fields.iter().any(|f| {
f.name == s
&& f.sortable
&& f.list_display
&& !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
})
})
}
fn extract_filters(raw: &HashMap<String, String>, fields: &[AdminField]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = raw
.iter()
.filter_map(|(k, v)| {
let name = k.strip_prefix("filter.")?;
if v.is_empty() {
return None;
}
if !fields.iter().any(|f| f.name == name && f.filterable) {
return None;
}
Some((name.to_owned(), v.clone()))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn admin_err(action: &str, err: AdminError) -> AutumnError {
match err {
AdminError::NotFound => AutumnError::not_found_msg(format!("{action}: not found")),
AdminError::Validation(msg) => AutumnError::bad_request_msg(format!("{action}: {msg}")),
AdminError::Database(msg) => {
AutumnError::internal_server_error_msg(format!("{action}: database error: {msg}"))
}
AdminError::Other(msg) => {
AutumnError::internal_server_error_msg(format!("{action}: {msg}"))
}
}
}
fn render(markup: maud::Markup) -> Response {
Html(markup.into_string()).into_response()
}
#[allow(clippy::too_many_arguments)]
fn invalid_form_response(
registry: &AdminRegistry,
slug: &str,
model: &dyn AdminModel,
fields: &[AdminField],
submitted: &Value,
raw_fields: &[&str],
error_message: String,
id: Option<i64>,
csrf: &AdminCsrf,
prefix: &str,
actuator_prefix: &str,
show_config: bool,
imp: &AdminImpersonation,
) -> Response {
let messages = [FlashMessage {
level: FlashLevel::Error,
message: error_message,
}];
let markup = templates::model_form_page(
registry,
slug,
model.display_name(),
model.display_name_plural(),
fields,
Some(submitted),
id,
raw_fields,
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
prefix,
actuator_prefix,
show_config,
imp.banner(prefix, csrf.token(), csrf.form_field()).as_ref(),
);
(StatusCode::UNPROCESSABLE_ENTITY, Html(markup.into_string())).into_response()
}
async fn merge_with_stored_record(
model: &dyn AdminModel,
pool: &Pool<::autumn_web::RuntimeConnection>,
id: i64,
submitted: &Value,
) -> Result<Value, AdminError> {
let mut base = model
.get(pool, id)
.await?
.unwrap_or_else(|| submitted.clone());
if let (Some(obj), Some(sub)) = (base.as_object_mut(), submitted.as_object()) {
for (k, v) in sub {
obj.insert(k.clone(), v.clone());
}
}
Ok(base)
}
fn extract_reveal_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
let cookie_str = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
cookie_str.split(';').find_map(|part| {
part.trim()
.strip_prefix("__autumn_reveal=")
.filter(|v| !v.is_empty())
.map(str::to_owned)
})
}
#[allow(clippy::too_many_arguments)]
async fn dashboard(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let pool = state
.pool()
.cloned()
.ok_or_else(|| AutumnError::service_unavailable_msg("No database pool configured"))?;
let futures: Vec<_> = registry
.iter()
.map(|(slug, model)| {
let pool = pool.clone();
async move {
let count = model.count(&pool).await.unwrap_or(0);
(slug, model.display_name_plural(), count)
}
})
.collect();
let counts = join_all(futures).await;
let messages = flash.consume().await;
Ok(render(templates::dashboard_page(
®istry,
&counts,
&messages,
csrf.token(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_arguments)]
async fn jobs_dashboard(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Query(query): Query<JobsQuery>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let mut snapshot = match job_admin_backend(&state) {
Some(backend) => backend.snapshot(query.into()).await?,
None => JobAdminSnapshot::empty(),
};
snapshot.schedules = scheduled_job_summaries(&state);
let messages = flash.consume().await;
Ok(render(templates::jobs_page(
®istry,
&snapshot,
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
async fn jobs_counters(
State(state): State<AppState>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Query(query): Query<JobsQuery>,
) -> AutumnResult<Response> {
let mut snapshot = match job_admin_backend(&state) {
Some(backend) => backend.snapshot(query.into()).await?,
None => JobAdminSnapshot::empty(),
};
snapshot.schedules = scheduled_job_summaries(&state);
Ok(render(templates::jobs_counters(&snapshot, &prefix)))
}
async fn job_retry(
State(state): State<AppState>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(id): Path<String>,
flash: Flash,
) -> AutumnResult<Response> {
let backend = job_admin_backend(&state)
.ok_or_else(|| AutumnError::service_unavailable_msg("job runtime is not initialized"))?;
backend.retry(&id).await?;
flash.success(format!("Retried job {id}.")).await;
Ok(Redirect::to(&format!("{prefix}/jobs")).into_response())
}
async fn job_discard(
State(state): State<AppState>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(id): Path<String>,
flash: Flash,
) -> AutumnResult<Response> {
let backend = job_admin_backend(&state)
.ok_or_else(|| AutumnError::service_unavailable_msg("job runtime is not initialized"))?;
backend.discard(&id).await?;
flash.success(format!("Discarded job {id}.")).await;
Ok(Redirect::to(&format!("{prefix}/jobs")).into_response())
}
async fn job_cancel(
State(state): State<AppState>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(id): Path<String>,
flash: Flash,
) -> AutumnResult<Response> {
let backend = job_admin_backend(&state)
.ok_or_else(|| AutumnError::service_unavailable_msg("job runtime is not initialized"))?;
backend.cancel(&id).await?;
flash.success(format!("Canceled job {id}.")).await;
Ok(Redirect::to(&format!("{prefix}/jobs")).into_response())
}
fn scheduled_job_summaries(state: &AppState) -> Vec<JobScheduleSummary> {
let mut schedules: Vec<_> = state
.task_registry()
.snapshot()
.into_iter()
.map(|(name, status)| {
let last_run_status = status
.last_error
.as_ref()
.map(|error| format!("failed: {error}"))
.or(status.last_result);
JobScheduleSummary {
name,
schedule: status.schedule,
next_run_at: status.next_run_at,
last_run_status,
}
})
.collect();
schedules.sort_by(|a, b| a.name.cmp(&b.name));
schedules
}
#[allow(clippy::too_many_arguments)]
async fn model_list(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path(slug): Path<String>,
Query(query): Query<ListQuery>,
Query(raw_query): Query<HashMap<String, String>>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let ListQuery { page, q, sort, dir } = query;
let page = page.max(1);
let per_page = model.per_page();
let fields = model.fields();
let sort = validate_sort_key(sort, &fields);
let filters = extract_filters(&raw_query, &fields);
let params = ListParams {
page,
per_page,
search: (!q.is_empty()).then(|| q.clone()),
sort_by: sort.clone(),
sort_dir: dir,
filters: filters.clone(),
};
let result = model
.list(&pool, params)
.await
.map_err(|e| admin_err("List", e))?;
let actions = model.actions();
let messages = flash.consume().await;
Ok(render(templates::model_list_page(
®istry,
&slug,
model.display_name_plural(),
&fields,
&actions,
&result,
&q,
sort.as_deref(),
dir,
&filters,
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
model.supports_csv_export(),
model.supports_csv_import(),
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_arguments)]
async fn model_new_form(
imp: AdminImpersonation,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path(slug): Path<String>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let model = registry
.get(&slug)
.ok_or_else(|| AutumnError::not_found_msg(format!("Model '{slug}' not found")))?;
let fields = model.fields();
let messages = flash.consume().await;
Ok(render(templates::model_form_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
None,
None,
&[],
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_arguments)]
async fn model_create(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path(slug): Path<String>,
csrf: AdminCsrf,
flash: Flash,
axum::extract::Form(form_data): axum::extract::Form<Value>,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let fields = model.fields();
let submitted = strip_meta_fields(form_data, &fields, false);
let form_data = match coerce_form_fields(submitted, &fields) {
Ok(v) => v,
Err((partial, field_names, msg)) => {
return Ok(invalid_form_response(
®istry,
&slug,
model,
&fields,
&partial,
&field_names,
msg,
None,
&csrf,
&prefix,
&actuator_prefix,
show_config,
&imp,
));
}
};
let record = match model.create(&pool, form_data.clone()).await {
Ok(record) => record,
Err(AdminError::Validation(msg)) => {
return Ok(invalid_form_response(
®istry,
&slug,
model,
&fields,
&form_data,
&[],
msg,
None,
&csrf,
&prefix,
&actuator_prefix,
show_config,
&imp,
));
}
Err(e) => return Err(admin_err("Create failed", e)),
};
let new_id = record_id(&record).ok_or_else(|| {
AutumnError::internal_server_error_msg(format!(
"{} create returned a record without a numeric `id` field; cannot route post-create redirect",
model.display_name()
))
})?;
let detail_path = format!("{prefix}/{slug}/{new_id}");
let mut response = Redirect::to(&detail_path).into_response();
if let Some(Value::String(secret)) = record.get("token") {
let secure_attr = if state.config_arc().session.secure {
"; Secure"
} else {
""
};
let cookie = format!(
"__autumn_reveal={secret}; HttpOnly{secure_attr}; SameSite=Strict; Path={detail_path}; Max-Age=300"
);
if let Ok(hv) = axum::http::HeaderValue::from_str(&cookie) {
response
.headers_mut()
.insert(axum::http::header::SET_COOKIE, hv);
}
} else {
flash
.success(format!("{} created.", model.display_name()))
.await;
}
Ok(response)
}
#[allow(clippy::too_many_arguments)]
async fn model_detail(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path((slug, id)): Path<(String, i64)>,
request_headers: axum::http::HeaderMap,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let record = model
.get(&pool, id)
.await
.map_err(|e| admin_err("Get", e))?
.ok_or_else(|| {
AutumnError::not_found_msg(format!("{} #{id} not found", model.display_name()))
})?;
let display = model.record_display(&record);
let fields = model.fields();
let reveal_secret = extract_reveal_cookie(&request_headers);
let mut messages = flash.consume().await;
if let Some(ref secret) = reveal_secret {
messages.push(FlashMessage {
level: FlashLevel::Info,
message: format!("Copy your token now — it will not be shown again: {secret}"),
});
}
let mut response = render(templates::model_detail_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
&record,
&display,
id,
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
model.has_history(),
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
))
.into_response();
if reveal_secret.is_some() {
let secure_attr = if state.config_arc().session.secure {
"; Secure"
} else {
""
};
let clear = format!(
"__autumn_reveal=; HttpOnly{secure_attr}; SameSite=Strict; Path={prefix}/{slug}/{id}; Max-Age=0"
);
if let Ok(hv) = axum::http::HeaderValue::from_str(&clear) {
response
.headers_mut()
.insert(axum::http::header::SET_COOKIE, hv);
}
}
Ok(response)
}
#[allow(clippy::too_many_arguments)]
async fn model_history(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path((slug, id)): Path<(String, i64)>,
Query(params): Query<HashMap<String, String>>,
csrf: AdminCsrf,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
if !model.has_history() {
return Err(AutumnError::not_found_msg(format!(
"{} does not have version history enabled",
model.display_name()
)));
}
let page: u64 = params.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
let per_page: u64 = params
.get("per_page")
.and_then(|p| p.parse().ok())
.unwrap_or(25);
let history = model
.get_history(&pool, id, page, per_page)
.await
.map_err(|e| admin_err("History", e))?;
Ok(render(templates::model_history_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
id,
&history,
&prefix,
&actuator_prefix,
csrf.token_header(),
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_arguments)]
async fn model_edit_form(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path((slug, id)): Path<(String, i64)>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let record = model
.get(&pool, id)
.await
.map_err(|e| admin_err("Get", e))?
.ok_or_else(|| {
AutumnError::not_found_msg(format!("{} #{id} not found", model.display_name()))
})?;
let fields = model.fields();
let messages = flash.consume().await;
Ok(render(templates::model_form_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
Some(&record),
Some(id),
&[],
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_arguments)]
async fn model_update(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path((slug, id)): Path<(String, i64)>,
csrf: AdminCsrf,
flash: Flash,
axum::extract::Form(form_data): axum::extract::Form<Value>,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let fields = model.fields();
let submitted = strip_meta_fields(form_data, &fields, true);
let form_data = match coerce_form_fields(submitted, &fields) {
Ok(v) => v,
Err((partial, field_names, msg)) => {
let display = match merge_with_stored_record(model, &pool, id, &partial).await {
Ok(v) => v,
Err(e) => return Err(admin_err("Update failed", e)),
};
return Ok(invalid_form_response(
®istry,
&slug,
model,
&fields,
&display,
&field_names,
msg,
Some(id),
&csrf,
&prefix,
&actuator_prefix,
show_config,
&imp,
));
}
};
if let Err(e) = model.update(&pool, id, form_data.clone()).await {
if let AdminError::Validation(msg) = e {
let display = match merge_with_stored_record(model, &pool, id, &form_data).await {
Ok(v) => v,
Err(e) => return Err(admin_err("Update failed", e)),
};
return Ok(invalid_form_response(
®istry,
&slug,
model,
&fields,
&display,
&[],
msg,
Some(id),
&csrf,
&prefix,
&actuator_prefix,
show_config,
&imp,
));
}
return Err(admin_err("Update failed", e));
}
flash
.success(format!("{} #{id} updated.", model.display_name()))
.await;
Ok(Redirect::to(&format!("{prefix}/{slug}/{id}")).into_response())
}
async fn model_action(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(slug): Path<String>,
flash: Flash,
body: axum::body::Bytes,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let mut action: Option<String> = None;
let mut ids: Vec<i64> = Vec::new();
let mut malformed_id = false;
for (k, v) in form_urlencoded::parse(&body) {
match k.as_ref() {
"action" => action = Some(v.into_owned()),
"ids" => match v.parse::<i64>() {
Ok(id) => ids.push(id),
Err(_) => malformed_id = true,
},
_ => {}
}
}
if malformed_id {
return Err(AutumnError::bad_request_msg(
"bulk action: one or more `ids` values were not valid integers",
));
}
let action = action
.ok_or_else(|| AutumnError::bad_request_msg("bulk action: missing `action` form field"))?;
if ids.is_empty() {
return Err(AutumnError::bad_request_msg(
"bulk action: select at least one row",
));
}
if !model.actions().iter().any(|a| a.name == action) {
return Err(AutumnError::bad_request_msg(format!(
"bulk action: '{action}' is not declared by this model"
)));
}
let count = model
.execute_action(&pool, &action, ids)
.await
.map_err(|e| admin_err("Bulk action failed", e))?;
flash
.success(format!("Applied '{action}' to {count} record(s)."))
.await;
Ok(Redirect::to(&format!("{prefix}/{slug}")).into_response())
}
async fn model_delete(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path((slug, id)): Path<(String, i64)>,
hx: HxRequest,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
model
.delete(&pool, id)
.await
.map_err(|e| admin_err("Delete failed", e))?;
flash
.success(format!("{} #{id} deleted.", model.display_name()))
.await;
let list_path = format!("{prefix}/{slug}");
if hx.is_htmx {
Ok(StatusCode::OK.hx_redirect(&list_path))
} else {
Ok(Redirect::to(&list_path).into_response())
}
}
const EXPORT_PAGE_SIZE: u64 = 500;
#[allow(clippy::too_many_arguments)]
async fn model_export_csv(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
Path(slug): Path<String>,
Query(query): Query<ListQuery>,
Query(raw_query): Query<HashMap<String, String>>,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
if !model.supports_csv_export() {
return Err(AutumnError::bad_request_msg(format!(
"Model '{slug}' does not support CSV export"
)));
}
let ListQuery {
page: _,
q,
sort,
dir,
} = query;
let fields = model.fields();
let sort_key = validate_sort_key(sort, &fields);
let filters = extract_filters(&raw_query, &fields);
let search = (!q.is_empty()).then_some(q);
let columns: Vec<&'static str> = model
.csv_export_columns()
.into_iter()
.filter(|name| !::autumn_web::confidential::is_confidential_column_name(name))
.collect();
let mut buf = Vec::new();
{
let mut wtr = csv::WriterBuilder::new()
.has_headers(true)
.from_writer(&mut buf);
wtr.write_record(&columns)
.map_err(|e| AutumnError::internal_server_error_msg(format!("CSV write error: {e}")))?;
let mut page: u64 = 1;
loop {
let params = ListParams {
page,
per_page: EXPORT_PAGE_SIZE,
search: search.clone(),
sort_by: sort_key.clone(),
sort_dir: dir,
filters: filters.clone(),
};
let result = model
.list(&pool, params)
.await
.map_err(|e| admin_err("Export", e))?;
let done =
result.records.len() < usize::try_from(EXPORT_PAGE_SIZE).unwrap_or(usize::MAX);
for record in &result.records {
let row = model.csv_export_row(&columns, record);
wtr.write_record(&row).map_err(|e| {
AutumnError::internal_server_error_msg(format!("CSV write error: {e}"))
})?;
}
if done {
break;
}
page += 1;
}
wtr.flush()
.map_err(|e| AutumnError::internal_server_error_msg(format!("CSV flush error: {e}")))?;
}
let filename = format!("{slug}.csv");
axum::response::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
)
.body(axum::body::Body::from(buf))
.map_err(|e| AutumnError::internal_server_error_msg(format!("Response error: {e}")))
}
#[allow(clippy::too_many_arguments)]
async fn model_import_form(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path(slug): Path<String>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let model = registry
.get(&slug)
.ok_or_else(|| AutumnError::not_found_msg(format!("Model '{slug}' not found")))?;
if !model.supports_csv_import() {
return Err(AutumnError::bad_request_msg(format!(
"Model '{slug}' does not support CSV import"
)));
}
let _ = state; let messages = flash.consume().await;
Ok(render(templates::model_import_form_page(
®istry,
&slug,
model.display_name_plural(),
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
async fn model_import_csv(
imp: AdminImpersonation,
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(HasRuntimeConfig(show_config)): axum::Extension<HasRuntimeConfig>,
Path(slug): Path<String>,
csrf: AdminCsrf,
flash: Flash,
mut multipart: Multipart,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
if !model.supports_csv_import() {
return Err(AutumnError::bad_request_msg(format!(
"Model '{slug}' does not support CSV import"
)));
}
let mut csv_bytes: Option<Vec<u8>> = None;
let mut import_mode = CsvImportMode::Insert;
while let Some(field) = multipart.next_field().await? {
match field.name() {
Some("file") => {
csv_bytes = Some(field.bytes_limited().await?);
}
Some("mode") => {
let bytes = field.bytes_limited().await?;
let val = String::from_utf8_lossy(&bytes).into_owned();
import_mode = CsvImportMode::from_form_value(&val).ok_or_else(|| {
AutumnError::bad_request_msg(format!("Unknown import mode: '{val}'"))
})?;
}
_ => {}
}
}
let csv_bytes = csv_bytes
.ok_or_else(|| AutumnError::bad_request_msg("No CSV file found in the multipart upload"))?;
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(csv_bytes.as_slice());
let headers: Vec<String> = rdr
.headers()
.map_err(|e| AutumnError::bad_request_msg(format!("CSV header error: {e}")))?
.iter()
.map(str::to_owned)
.collect();
let mut report = AdminImportReport::default();
for result in rdr.records() {
match result {
Ok(record) => {
let line = record.position().map_or(0, csv::Position::line);
let row: std::collections::HashMap<String, String> = headers
.iter()
.zip(record.iter())
.map(|(k, v)| (k.clone(), v.to_owned()))
.collect();
let outcome = model
.import_csv_row(&pool, line, row, import_mode)
.await
.unwrap_or_else(|e| AdminImportRowResult::RowError(e.to_string()));
match outcome {
AdminImportRowResult::Inserted => report.inserted += 1,
AdminImportRowResult::Updated => report.updated += 1,
AdminImportRowResult::Skipped => report.skipped += 1,
AdminImportRowResult::RowError(msg) => {
report.errors.push(AdminImportError {
line,
column: None,
message: msg,
});
}
AdminImportRowResult::FieldError { column, message } => {
report.errors.push(AdminImportError {
line,
column: Some(column),
message,
});
}
}
}
Err(e) => {
let line = e.position().map_or(0, csv::Position::line);
report.errors.push(AdminImportError {
line,
column: None,
message: format!("CSV parse error: {e}"),
});
}
}
}
let messages = flash.consume().await;
Ok(render(templates::model_import_result_page(
®istry,
&slug,
model.display_name_plural(),
&report,
import_mode,
&messages,
csrf.token(),
csrf.token_header(),
&prefix,
&actuator_prefix,
show_config,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
async fn config_list(
imp: AdminImpersonation,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(svc): axum::Extension<Arc<RuntimeConfigService>>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let entries = svc
.list()
.map_err(|e| AutumnError::internal_server_error_msg(format!("Runtime config: {e}")))?;
let messages = flash.consume().await;
Ok(render(templates::config_page(
®istry,
&entries,
&messages,
csrf.token(),
csrf.form_field(),
csrf.token_header(),
&prefix,
&actuator_prefix,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
async fn config_set(
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(AdminAuthSessionKey(auth_session_key)): axum::Extension<AdminAuthSessionKey>,
axum::Extension(svc): axum::Extension<Arc<RuntimeConfigService>>,
session: autumn_web::session::Session,
Path(key): Path<String>,
flash: Flash,
axum::extract::Form(form): axum::extract::Form<HashMap<String, String>>,
) -> AutumnResult<Response> {
let value = form.get("value").map_or("", String::as_str);
let actor = config_actor(&session, &auth_session_key).await;
match svc.set(&key, value, Some(&actor)) {
Ok(()) => flash.success(format!("Updated {key} = {value}")).await,
Err(e) => flash.error(format!("Failed to set {key}: {e}")).await,
}
Ok(Redirect::to(&format!("{prefix}/config")).into_response())
}
async fn config_actor(session: &autumn_web::session::Session, auth_session_key: &str) -> String {
if let Some(actor) = autumn_web::current::Current::actor() {
return actor;
}
session
.get(auth_session_key)
.await
.unwrap_or_else(|| "admin-ui".to_owned())
}
async fn config_unset(
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(AdminAuthSessionKey(auth_session_key)): axum::Extension<AdminAuthSessionKey>,
axum::Extension(svc): axum::Extension<Arc<RuntimeConfigService>>,
session: autumn_web::session::Session,
Path(key): Path<String>,
flash: Flash,
) -> AutumnResult<Response> {
let actor = config_actor(&session, &auth_session_key).await;
match svc.unset(&key, Some(&actor)) {
Ok(()) => flash.success(format!("Reset {key} to default")).await,
Err(e) => flash.error(format!("Failed to reset {key}: {e}")).await,
}
Ok(Redirect::to(&format!("{prefix}/config")).into_response())
}
#[allow(clippy::too_many_arguments)]
async fn config_key_history(
imp: AdminImpersonation,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
axum::Extension(svc): axum::Extension<Arc<RuntimeConfigService>>,
Path(key): Path<String>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let history = svc
.history(&key, 50)
.map_err(|e| AutumnError::internal_server_error_msg(format!("Runtime config: {e}")))?;
let messages = flash.consume().await;
Ok(render(templates::config_history_page(
®istry,
&key,
&history,
&messages,
csrf.token(),
csrf.token_header(),
&prefix,
&actuator_prefix,
imp.banner(&prefix, csrf.token(), csrf.form_field())
.as_ref(),
)))
}
fn strip_meta_fields(mut data: Value, fields: &[AdminField], for_update: bool) -> Value {
if let Some(obj) = data.as_object_mut() {
obj.retain(|k, v| {
if k.starts_with('_') {
return false;
}
let Some(field) = fields.iter().find(|f| f.name == k) else {
return false;
};
if matches!(field.kind, AdminFieldKind::Hidden) {
return false;
}
if !field.editable {
return false;
}
if for_update && field.create_only {
return false;
}
!matches!(v, Value::String(s) if s.is_empty() && matches!(field.kind, AdminFieldKind::Password))
});
}
data
}
fn coerce_form_fields(
mut data: Value,
fields: &[AdminField],
) -> Result<Value, (Value, Vec<&'static str>, String)> {
let mut failed_names = Vec::new();
let mut messages = Vec::new();
if let Some(obj) = data.as_object_mut() {
for field in fields {
let Some(value) = obj.get_mut(field.name) else {
continue;
};
if let Err(msg) = coerce_form_value(value, field) {
failed_names.push(field.name);
messages.push(msg);
}
}
}
if messages.is_empty() {
Ok(data)
} else {
Err((data, failed_names, messages.join("; ")))
}
}
fn coerce_form_value(value: &mut Value, field: &AdminField) -> Result<(), String> {
if !field.required
&& field.kind.blank_submission_is_null()
&& matches!(value, Value::String(raw) if raw.trim().is_empty())
{
*value = Value::Null;
return Ok(());
}
match &field.kind {
AdminFieldKind::Boolean => {
if let Value::String(raw) = value
&& let Some(parsed) = parse_form_bool(raw)
{
*value = Value::Bool(parsed);
}
}
AdminFieldKind::Integer => {
if let Value::String(raw) = value
&& let Ok(parsed) = raw.parse::<i64>()
{
*value = Value::Number(parsed.into());
}
}
AdminFieldKind::Float => {
if let Value::String(raw) = value
&& let Ok(parsed) = raw.parse::<f64>()
&& let Some(number) = serde_json::Number::from_f64(parsed)
{
*value = Value::Number(number);
}
}
AdminFieldKind::Json => {
if let Value::String(raw) = value
&& !raw.is_empty()
{
match serde_json::from_str::<Value>(raw) {
Ok(parsed) => *value = parsed,
Err(err) => return Err(format!("{}: invalid JSON ({err})", field.name)),
}
}
}
AdminFieldKind::Text
| AdminFieldKind::TextArea
| AdminFieldKind::Date
| AdminFieldKind::DateTime
| AdminFieldKind::Select(_)
| AdminFieldKind::Hidden
| AdminFieldKind::Password => {}
}
Ok(())
}
fn parse_form_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" | "" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::{AdminFuture, ListResult, SelectOption};
use autumn_web::job::{JobAdminBackendEntry, JobAdminMemoryBackend};
use autumn_web::session::Session;
use axum::body::Body;
use serde_json::json;
use std::collections::HashMap;
use tower::ServiceExt;
fn fields(specs: &[(&'static str, AdminFieldKind)]) -> Vec<AdminField> {
specs
.iter()
.cloned()
.map(|(name, kind)| AdminField::new(name, kind))
.collect()
}
fn lazy_pool() -> Pool<::autumn_web::RuntimeConnection> {
let config = autumn_web::config::DatabaseConfig {
url: Some("postgres://localhost/unused".into()),
pool_size: 1,
..Default::default()
};
autumn_web::db::create_pool(&config)
.expect("pool config")
.expect("pool")
}
struct FormTestModel;
impl AdminModel for FormTestModel {
fn slug(&self) -> &'static str {
"widgets"
}
fn display_name(&self) -> &'static str {
"Widget"
}
fn display_name_plural(&self) -> &'static str {
"Widgets"
}
fn fields(&self) -> Vec<AdminField> {
vec![
AdminField::new("name", AdminFieldKind::Text),
AdminField::new("owner", AdminFieldKind::Text).create_only(),
AdminField::new("metadata", AdminFieldKind::Json),
AdminField::new("api_key", AdminFieldKind::Text)
.optional()
.encrypted(),
AdminField::new("published", AdminFieldKind::Boolean).optional(),
]
}
fn list(
&self,
_pool: &Pool<::autumn_web::RuntimeConnection>,
_params: ListParams,
) -> AdminFuture<'_, ListResult> {
Box::pin(async {
Ok(ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
})
})
}
fn get(
&self,
_pool: &Pool<::autumn_web::RuntimeConnection>,
id: i64,
) -> AdminFuture<'_, Option<Value>> {
Box::pin(async move {
if id == 1 {
Ok(Some(json!({
"id": 1,
"name": "Widget One",
"owner": "alice",
"metadata": {"a": 1},
})))
} else if id == 999 {
Err(AdminError::Database("simulated outage".into()))
} else {
Ok(None)
}
})
}
fn create(
&self,
_pool: &Pool<::autumn_web::RuntimeConnection>,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move {
if data.get("name").and_then(Value::as_str) == Some("taken") {
return Err(AdminError::Validation("name already taken".into()));
}
let mut record = data;
if let Some(obj) = record.as_object_mut() {
obj.insert("id".into(), json!(2));
}
Ok(record)
})
}
fn update(
&self,
_pool: &Pool<::autumn_web::RuntimeConnection>,
_id: i64,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move {
if data.get("name").and_then(Value::as_str) == Some("taken") {
return Err(AdminError::Validation("name already taken".into()));
}
Ok(data)
})
}
fn delete(
&self,
_pool: &Pool<::autumn_web::RuntimeConnection>,
_id: i64,
) -> AdminFuture<'_, ()> {
Box::pin(async { Ok(()) })
}
}
fn form_test_app() -> axum::Router {
let mut registry = AdminRegistry::new();
registry.register(FormTestModel);
let session = Session::new_for_test("sid".to_owned(), HashMap::new());
let state = AppState::for_test().with_pool(lazy_pool());
admin_router(
Arc::new(registry),
"/admin",
"/actuator".to_owned(),
"user_id".to_owned(),
None,
None,
false,
autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
false,
)
.layer(axum::Extension(session))
.with_state(state)
}
async fn post_form(
app: axum::Router,
uri: &str,
pairs: &[(&str, &str)],
) -> (StatusCode, String) {
let body = {
let mut ser = form_urlencoded::Serializer::new(String::new());
for (k, v) in pairs {
ser.append_pair(k, v);
}
ser.finish()
};
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::ACCEPT, "text/html,application/xhtml+xml")
.body(Body::from(body))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
(status, String::from_utf8(bytes.to_vec()).expect("utf8"))
}
#[tokio::test]
async fn model_create_malformed_json_redisplays_form_with_entered_data() {
let (status, html) = post_form(
form_test_app(),
"/widgets",
&[("name", "Widget X"), ("metadata", "{broken")],
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "html: {html}");
assert!(html.contains("New Widget"), "html: {html}");
assert!(html.contains(r#"value="Widget X""#), "html: {html}");
assert!(html.contains("invalid JSON"), "html: {html}");
assert!(!html.contains("Go to homepage"), "html: {html}");
assert!(
html.contains(">{broken</textarea>"),
"malformed JSON must render raw, unquoted: {html}"
);
assert!(
!html.contains(r""{broken"") && !html.contains(r#">"{broken"</textarea>"#),
"malformed JSON must not be re-quoted: {html}"
);
}
#[tokio::test]
async fn model_create_malformed_json_keeps_encrypted_field_editable() {
let (status, html) = post_form(
form_test_app(),
"/widgets",
&[
("name", "Widget X"),
("metadata", "{broken"),
("api_key", "sk-live-123"),
],
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "html: {html}");
assert!(
html.contains(r#"name="api_key""#),
"encrypted field must stay submittable on a create failure: {html}"
);
assert!(
!html.contains("••••••••"),
"encrypted field must not show the edit-only redaction mask on create: {html}"
);
assert!(
!html.contains("sk-live-123"),
"encrypted field's plaintext must not leak into the redisplayed HTML: {html}"
);
}
#[tokio::test]
async fn model_create_malformed_json_keeps_other_fields_coerced() {
let (status, html) = post_form(
form_test_app(),
"/widgets",
&[
("name", "Widget X"),
("metadata", "{broken"),
("published", "true"),
],
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "html: {html}");
assert!(
html.contains(r#"id="published" value="true" checked"#),
"a later field must still coerce and redisplay checked: {html}"
);
}
#[tokio::test]
async fn model_create_validation_error_redisplays_form_with_entered_data() {
let (status, html) = post_form(
form_test_app(),
"/widgets",
&[("name", "taken"), ("owner", "bob"), ("metadata", "{}")],
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "html: {html}");
assert!(html.contains("New Widget"), "html: {html}");
assert!(html.contains(r#"value="taken""#), "html: {html}");
assert!(html.contains("name already taken"), "html: {html}");
assert!(!html.contains("Go to homepage"), "html: {html}");
}
#[tokio::test]
async fn model_update_validation_error_preserves_create_only_field_and_entered_data() {
let (status, html) = post_form(
form_test_app(),
"/widgets/1",
&[("name", "taken"), ("metadata", "{}")],
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "html: {html}");
assert!(html.contains("Edit Widget"), "html: {html}");
assert!(html.contains(r#"value="taken""#), "html: {html}");
assert!(html.contains("name already taken"), "html: {html}");
assert!(html.contains("alice"), "html: {html}");
assert!(!html.contains("Go to homepage"), "html: {html}");
}
#[tokio::test]
async fn model_update_validation_error_propagates_a_refetch_backend_failure() {
let (status, body) = post_form(
form_test_app(),
"/widgets/999",
&[("name", "taken"), ("metadata", "{}")],
)
.await;
assert_ne!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"a backend outage on the refetch must not be reported as a validation failure: {body}"
);
assert!(status.is_server_error(), "status: {status}, body: {body}");
assert!(body.contains("simulated outage"), "body: {body}");
}
#[tokio::test]
async fn jobs_route_renders_without_database_pool() {
let backend = JobAdminMemoryBackend::new();
let state =
AppState::for_test().with_extension(JobAdminBackendEntry(std::sync::Arc::new(backend)));
state.task_registry().register_scheduled(
"cleanup",
"every 60s",
autumn_web::task::TaskCoordination::Fleet,
"local",
"replica-a",
);
state
.task_registry()
.record_next_run_at("cleanup", "2026-05-08T12:00:00Z");
let session = Session::new_for_test("sid".to_owned(), HashMap::new());
let app = admin_router(
std::sync::Arc::new(AdminRegistry::new()),
"/admin",
"/actuator".to_owned(),
"user_id".to_owned(),
None,
None,
false,
autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
false,
)
.layer(axum::Extension(session))
.with_state(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/jobs")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("Jobs"), "html: {html}");
assert!(html.contains("Enqueued"), "html: {html}");
assert!(
html.contains(r#"hx-get="/admin/jobs/counters""#),
"html: {html}"
);
assert!(html.contains("cleanup"), "html: {html}");
assert!(html.contains("2026-05-08T12:00:00Z"), "html: {html}");
}
#[test]
fn strip_meta_removes_csrf_and_underscore_fields() {
let input = json!({"name": "x", "_csrf": "t", "_foo": 1});
let out = strip_meta_fields(input, &fields(&[("name", AdminFieldKind::Text)]), false);
assert_eq!(out, json!({"name": "x"}));
}
#[test]
fn strip_meta_drops_blank_password_by_declared_kind() {
let fields = fields(&[
("password", AdminFieldKind::Password),
("other", AdminFieldKind::Text),
]);
let out = strip_meta_fields(json!({"password": "", "other": "y"}), &fields, false);
assert_eq!(out, json!({"other": "y"}));
let out = strip_meta_fields(json!({"password": "hunter2", "other": "y"}), &fields, false);
assert_eq!(out, json!({"password": "hunter2", "other": "y"}));
}
#[test]
fn strip_meta_drops_blank_custom_named_password() {
let fields = fields(&[("secret", AdminFieldKind::Password)]);
let out = strip_meta_fields(json!({"secret": ""}), &fields, false);
assert_eq!(out, json!({}));
}
#[test]
fn strip_meta_preserves_blank_non_password_fields() {
let fields = fields(&[
("name", AdminFieldKind::Text),
("bio", AdminFieldKind::TextArea),
]);
let out = strip_meta_fields(json!({"name": "", "bio": ""}), &fields, false);
assert_eq!(out, json!({"name": "", "bio": ""}));
}
#[test]
fn coerce_form_fields_converts_boolean_strings() {
let fields = fields(&[("published", AdminFieldKind::Boolean)]);
let out = coerce_form_fields(json!({"published": "true"}), &fields).unwrap();
assert_eq!(out, json!({"published": true}));
let out = coerce_form_fields(json!({"published": "false"}), &fields).unwrap();
assert_eq!(out, json!({"published": false}));
}
#[test]
fn coerce_form_fields_converts_numeric_and_json_strings() {
let fields = fields(&[
("count", AdminFieldKind::Integer),
("rating", AdminFieldKind::Float),
("settings", AdminFieldKind::Json),
]);
let out = coerce_form_fields(
json!({
"count": "42",
"rating": "3.5",
"settings": "{\"published\":true}"
}),
&fields,
)
.unwrap();
assert_eq!(
out,
json!({
"count": 42,
"rating": 3.5,
"settings": {"published": true}
})
);
}
#[test]
fn coerce_form_fields_converts_blank_optional_numeric_strings_to_null() {
let fields = vec![
AdminField::new("count", AdminFieldKind::Integer).optional(),
AdminField::new("rating", AdminFieldKind::Float).optional(),
];
let out = coerce_form_fields(json!({"count": "", "rating": ""}), &fields).unwrap();
assert_eq!(out, json!({"count": null, "rating": null}));
}
#[test]
fn coerce_form_fields_converts_blank_optional_date_strings_to_null() {
let fields = vec![
AdminField::new("published_on", AdminFieldKind::Date).optional(),
AdminField::new("starts_at", AdminFieldKind::DateTime).optional(),
];
let out =
coerce_form_fields(json!({"published_on": "", "starts_at": " "}), &fields).unwrap();
assert_eq!(out, json!({"published_on": null, "starts_at": null}));
}
#[test]
fn coerce_form_fields_converts_blank_optional_textish_strings_to_null() {
let fields = vec![
AdminField::new("token", AdminFieldKind::Text).optional(),
AdminField::new("notes", AdminFieldKind::TextArea).optional(),
AdminField::new(
"status",
AdminFieldKind::Select(vec![SelectOption {
value: "a".to_owned(),
label: "A".to_owned(),
}]),
)
.optional(),
];
let out = coerce_form_fields(json!({"token": "", "notes": " ", "status": ""}), &fields)
.unwrap();
assert_eq!(out, json!({"token": null, "notes": null, "status": null}));
}
#[test]
fn coerce_form_fields_converts_blank_optional_json_to_null() {
let fields = vec![AdminField::new("settings", AdminFieldKind::Json).optional()];
let out = coerce_form_fields(json!({"settings": ""}), &fields).unwrap();
assert_eq!(out, json!({"settings": null}));
let out = coerce_form_fields(json!({"settings": " "}), &fields).unwrap();
assert_eq!(out, json!({"settings": null}));
let out = coerce_form_fields(json!({"settings": "{\"published\":true}"}), &fields).unwrap();
assert_eq!(out, json!({"settings": {"published": true}}));
}
#[test]
fn coerce_form_fields_keeps_blank_required_json_as_empty_string() {
let fields = vec![AdminField::new("settings", AdminFieldKind::Json)];
let out = coerce_form_fields(json!({"settings": ""}), &fields).unwrap();
assert_eq!(out, json!({"settings": ""}));
}
#[test]
fn coerce_form_fields_keeps_blank_required_text_as_empty_string() {
let fields = vec![AdminField::new("token", AdminFieldKind::Text)];
let out = coerce_form_fields(json!({"token": ""}), &fields).unwrap();
assert_eq!(out, json!({"token": ""}));
}
#[test]
fn coerce_form_fields_rejects_malformed_non_blank_json() {
let fields = vec![AdminField::new("settings", AdminFieldKind::Json)];
let (partial, field_names, msg) =
coerce_form_fields(json!({"settings": "{broken"}), &fields)
.expect_err("malformed non-blank JSON must be rejected, not silently stored");
assert_eq!(
field_names,
vec!["settings"],
"error should name the offending field"
);
assert!(msg.contains("settings"), "error message: {msg}");
assert_eq!(partial, json!({"settings": "{broken"}));
let fields = vec![AdminField::new("settings", AdminFieldKind::Json).optional()];
assert!(coerce_form_fields(json!({"settings": "{broken"}), &fields).is_err());
let fields = vec![AdminField::new("settings", AdminFieldKind::Json)];
let out = coerce_form_fields(json!({"settings": "\"not broken\""}), &fields).unwrap();
assert_eq!(out, json!({"settings": "not broken"}));
}
#[test]
fn coerce_form_fields_partial_result_keeps_earlier_fields_coerced() {
let fields = vec![
AdminField::new("count", AdminFieldKind::Integer),
AdminField::new("settings", AdminFieldKind::Json),
];
let (partial, field_names, _msg) =
coerce_form_fields(json!({"count": "42", "settings": "{broken"}), &fields)
.expect_err("malformed JSON must be rejected");
assert_eq!(field_names, vec!["settings"]);
assert_eq!(partial, json!({"count": 42, "settings": "{broken"}));
}
#[test]
fn coerce_form_fields_still_coerces_fields_declared_after_a_failure() {
let fields = vec![
AdminField::new("settings", AdminFieldKind::Json),
AdminField::new("published", AdminFieldKind::Boolean),
];
let (partial, field_names, _msg) =
coerce_form_fields(json!({"settings": "{broken", "published": "true"}), &fields)
.expect_err("malformed JSON must be rejected");
assert_eq!(field_names, vec!["settings"]);
assert_eq!(
partial,
json!({"settings": "{broken", "published": true}),
"a field declared after the failing one must still coerce"
);
}
#[test]
fn coerce_form_fields_collects_every_failing_field() {
let fields = vec![
AdminField::new("a", AdminFieldKind::Json),
AdminField::new("b", AdminFieldKind::Json),
];
let (partial, field_names, msg) =
coerce_form_fields(json!({"a": "{broken", "b": "[also-broken"}), &fields)
.expect_err("both malformed fields must be rejected");
assert_eq!(field_names, vec!["a", "b"]);
assert!(msg.contains('a') && msg.contains('b'), "message: {msg}");
assert_eq!(partial, json!({"a": "{broken", "b": "[also-broken"}));
}
#[test]
fn coerce_form_fields_rejects_whitespace_only_required_json() {
let fields = vec![AdminField::new("settings", AdminFieldKind::Json)];
assert!(
coerce_form_fields(json!({"settings": " "}), &fields).is_err(),
"whitespace-only required JSON must be rejected, not silently stored"
);
let out = coerce_form_fields(json!({"settings": ""}), &fields).unwrap();
assert_eq!(out, json!({"settings": ""}));
}
#[test]
fn validate_sort_key_passes_known_sortable_displayed_fields() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(
validate_sort_key(Some("name".to_owned()), &fields),
Some("name".to_owned())
);
}
#[test]
fn validate_sort_key_drops_unknown_keys() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(
validate_sort_key(Some("DROP TABLE users".into()), &fields),
None
);
assert_eq!(validate_sort_key(Some("password".into()), &fields), None);
}
#[test]
fn validate_sort_key_drops_non_sortable_fields() {
let mut computed = AdminField::new("computed", AdminFieldKind::Text);
computed.sortable = false;
let schema = vec![computed];
assert_eq!(validate_sort_key(Some("computed".into()), &schema), None);
}
#[test]
fn validate_sort_key_drops_hidden_columns() {
let mut secret = AdminField::new("secret", AdminFieldKind::Text);
secret.list_display = false;
let schema = vec![secret];
assert_eq!(validate_sort_key(Some("secret".into()), &schema), None);
}
#[test]
fn validate_sort_key_drops_sensitive_kinds_even_if_flagged_sortable() {
let pw = AdminField::new("password_hash", AdminFieldKind::Password);
let hidden = AdminField::new("internal_token", AdminFieldKind::Hidden);
let schema = vec![pw, hidden];
assert_eq!(
validate_sort_key(Some("password_hash".into()), &schema),
None
);
assert_eq!(
validate_sort_key(Some("internal_token".into()), &schema),
None
);
}
#[test]
fn extract_filters_keeps_declared_filterable_fields() {
let mut status = AdminField::new("status", AdminFieldKind::Text);
status.filterable = true;
let schema = vec![status, AdminField::new("name", AdminFieldKind::Text)];
let raw = HashMap::from([
("filter.status".into(), "active".into()),
("filter.name".into(), "alice".into()), ("page".into(), "1".into()), ("filter.unknown".into(), "x".into()), ]);
let out = extract_filters(&raw, &schema);
assert_eq!(out, vec![("status".to_owned(), "active".to_owned())]);
}
#[test]
fn extract_filters_drops_empty_values() {
let mut status = AdminField::new("status", AdminFieldKind::Text);
status.filterable = true;
let schema = vec![status];
let raw = HashMap::from([("filter.status".into(), String::new())]);
assert_eq!(extract_filters(&raw, &schema), vec![]);
}
#[test]
fn extract_filters_handles_no_filters() {
let schema = vec![AdminField::new("name", AdminFieldKind::Text)];
let raw = HashMap::from([("page".into(), "2".into()), ("q".into(), "x".into())]);
assert_eq!(extract_filters(&raw, &schema), vec![]);
}
#[test]
fn extract_filters_sorts_for_stable_output() {
let mut a = AdminField::new("zeta", AdminFieldKind::Text);
a.filterable = true;
let mut b = AdminField::new("alpha", AdminFieldKind::Text);
b.filterable = true;
let schema = vec![a, b];
let raw = HashMap::from([
("filter.zeta".into(), "z".into()),
("filter.alpha".into(), "a".into()),
]);
let out = extract_filters(&raw, &schema);
assert_eq!(
out,
vec![
("alpha".to_owned(), "a".to_owned()),
("zeta".to_owned(), "z".to_owned()),
]
);
}
#[test]
fn validate_sort_key_passes_through_none() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(validate_sort_key(None, &fields), None);
}
#[test]
fn admin_err_maps_variants_to_correct_status() {
use axum::http::StatusCode;
assert_eq!(
admin_err("X", AdminError::NotFound).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
admin_err("X", AdminError::Validation("bad".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
admin_err("X", AdminError::Database("pg down".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
admin_err("X", AdminError::Other("boom".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn admin_csrf_extractor_returns_empty_when_layer_missing() {
let req = axum::http::Request::builder().uri("/").body(()).unwrap();
let (mut parts, ()) = req.into_parts();
let extracted = AdminCsrf::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert_eq!(extracted.token(), "");
assert_eq!(extracted.form_field(), "_csrf");
}
#[tokio::test]
async fn admin_csrf_extractor_reads_token_from_extensions() {
use axum::Router;
use axum::body::Body;
use axum::http::StatusCode;
use axum::routing::get;
use tower::ServiceExt;
async fn handler(csrf: AdminCsrf) -> String {
csrf.token().to_owned()
}
let app = Router::new().route("/", get(handler));
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_csrf_extractor_reads_configured_form_field_from_extensions() {
let req = axum::http::Request::builder().uri("/").body(()).unwrap();
let (mut parts, ()) = req.into_parts();
parts
.extensions
.insert(CsrfFormField("authenticity_token".to_owned()));
let extracted = AdminCsrf::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert_eq!(extracted.token(), "");
assert_eq!(extracted.form_field(), "authenticity_token");
}
#[test]
fn strip_meta_keeps_field_named_password_if_not_declared_as_such() {
let fields = fields(&[("password", AdminFieldKind::Text)]);
let out = strip_meta_fields(json!({"password": ""}), &fields, false);
assert_eq!(out, json!({"password": ""}));
}
#[test]
fn strip_meta_drops_fields_not_in_schema() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
let input = json!({"name": "x", "is_admin": true, "raw_column": "y"});
let out = strip_meta_fields(input, &fields, false);
assert_eq!(out, json!({"name": "x"}));
}
#[test]
fn strip_meta_drops_hidden_fields_even_if_editable_true() {
let mut hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
hidden.editable = true; let schema = vec![hidden];
let out = strip_meta_fields(json!({"owner_id": 999}), &schema, false);
assert_eq!(out, json!({}));
}
#[test]
fn strip_meta_drops_create_only_fields_on_update_but_keeps_on_create() {
let mut principal = AdminField::new("principal_id", AdminFieldKind::Text);
principal.create_only = true;
let name = AdminField::new("name", AdminFieldKind::Text);
let schema = vec![principal, name];
let input = json!({"principal_id": "svc:x", "name": "my-token"});
let out = strip_meta_fields(input.clone(), &schema, false);
assert_eq!(out, json!({"principal_id": "svc:x", "name": "my-token"}));
let out = strip_meta_fields(input, &schema, true);
assert_eq!(out, json!({"name": "my-token"}));
}
#[test]
fn strip_meta_drops_readonly_fields() {
let mut id = AdminField::new("id", AdminFieldKind::Integer);
id.editable = false;
let mut created_at = AdminField::new("created_at", AdminFieldKind::DateTime);
created_at.editable = false;
let name = AdminField::new("name", AdminFieldKind::Text);
let schema = vec![id, created_at, name];
let input = json!({
"id": 999,
"created_at": "2026-01-01T00:00:00Z",
"name": "legit",
});
let out = strip_meta_fields(input, &schema, false);
assert_eq!(out, json!({"name": "legit"}));
}
#[test]
fn parse_form_bool_recognizes_truthy_falsy_and_unknown_variants() {
assert_eq!(parse_form_bool("true"), Some(true));
assert_eq!(parse_form_bool("1"), Some(true));
assert_eq!(parse_form_bool("yes"), Some(true));
assert_eq!(parse_form_bool("on"), Some(true));
assert_eq!(parse_form_bool("TRUE"), Some(true)); assert_eq!(parse_form_bool("YES"), Some(true));
assert_eq!(parse_form_bool("false"), Some(false));
assert_eq!(parse_form_bool("0"), Some(false));
assert_eq!(parse_form_bool("no"), Some(false));
assert_eq!(parse_form_bool("off"), Some(false));
assert_eq!(parse_form_bool(""), Some(false));
assert_eq!(parse_form_bool(" "), Some(false)); assert_eq!(parse_form_bool("maybe"), None);
assert_eq!(parse_form_bool("y"), None);
assert_eq!(parse_form_bool("2"), None);
}
fn headers_with_cookie(cookie: &str) -> axum::http::HeaderMap {
let mut map = axum::http::HeaderMap::new();
map.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_str(cookie).unwrap(),
);
map
}
#[test]
fn extract_reveal_cookie_returns_none_when_no_cookie_header() {
let headers = axum::http::HeaderMap::new();
assert!(extract_reveal_cookie(&headers).is_none());
}
#[test]
fn extract_reveal_cookie_returns_none_when_cookie_absent_from_header() {
let headers = headers_with_cookie("session=abc; other=xyz");
assert!(extract_reveal_cookie(&headers).is_none());
}
#[test]
fn extract_reveal_cookie_returns_none_when_value_is_empty() {
let headers = headers_with_cookie("__autumn_reveal=; session=abc");
assert!(extract_reveal_cookie(&headers).is_none());
}
#[test]
fn extract_reveal_cookie_returns_value_when_present() {
let headers = headers_with_cookie("session=abc; __autumn_reveal=tok123; other=xyz");
assert_eq!(extract_reveal_cookie(&headers).as_deref(), Some("tok123"));
}
#[test]
fn extract_reveal_cookie_handles_leading_only_cookie() {
let headers = headers_with_cookie("__autumn_reveal=supersecret");
assert_eq!(
extract_reveal_cookie(&headers).as_deref(),
Some("supersecret")
);
}
}