mod auth;
mod registry;
mod routes;
mod templates;
mod traits;
pub use registry::AdminRegistry;
pub use traits::{
AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams,
ListResult, SortDirection,
};
pub mod prelude {
pub use crate::{
AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
SortDirection,
};
}
use std::borrow::Cow;
use std::sync::Arc;
use autumn_web::app::AppBuilder;
use autumn_web::plugin::Plugin;
use autumn_web::route_listing::RouteInfo;
pub struct AdminPlugin {
registry: AdminRegistry,
prefix: String,
actuator_prefix: String,
auth_session_key: String,
require_role: Option<String>,
}
impl AdminPlugin {
#[must_use]
pub fn new() -> Self {
Self {
registry: AdminRegistry::new(),
prefix: "/admin".to_owned(),
actuator_prefix: "/actuator".to_owned(),
auth_session_key: "user_id".to_owned(),
require_role: Some("admin".to_owned()),
}
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
#[must_use]
pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
self.actuator_prefix = prefix.into();
self
}
#[must_use]
pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
self.auth_session_key = key.into();
self
}
#[must_use]
pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
self.require_role = role.into();
self
}
#[must_use]
pub fn register<M: AdminModel>(mut self, model: M) -> Self {
self.registry.register(model);
self
}
}
impl Default for AdminPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for AdminPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("autumn-admin-plugin")
}
fn build(self, app: AppBuilder) -> AppBuilder {
let Self {
registry,
prefix,
actuator_prefix,
auth_session_key,
require_role,
} = self;
let registry = Arc::new(registry);
let router = routes::admin_router(
Arc::clone(®istry),
&prefix,
actuator_prefix.clone(),
auth_session_key.clone(),
require_role.clone(),
);
tracing::info!(
prefix = %prefix,
actuator_prefix = %actuator_prefix,
auth_session_key = %auth_session_key,
models = registry.model_count(),
role = require_role.as_deref().unwrap_or("<none>"),
"🍂 Autumn Admin mounted"
);
let declared = admin_route_infos(&prefix);
app.nest(&prefix, router).declare_plugin_routes(declared)
}
}
pub(crate) fn admin_route_infos(prefix: &str) -> Vec<RouteInfo> {
[
("GET", prefix.to_string()),
("GET", format!("{prefix}/jobs")),
("GET", format!("{prefix}/jobs/counters")),
("POST", format!("{prefix}/jobs/{{id}}/retry")),
("POST", format!("{prefix}/jobs/{{id}}/discard")),
("POST", format!("{prefix}/jobs/{{id}}/cancel")),
("GET", format!("{prefix}/{{slug}}")),
("POST", format!("{prefix}/{{slug}}")),
("GET", format!("{prefix}/{{slug}}/new")),
("GET", format!("{prefix}/{{slug}}/{{id}}")),
("POST", format!("{prefix}/{{slug}}/{{id}}")),
("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
("POST", format!("{prefix}/{{slug}}/actions")),
("GET", format!("{prefix}{}", &*routes::ADMIN_JS_PATH)),
]
.into_iter()
.map(|(method, path)| RouteInfo {
method: method.to_owned(),
path,
handler: format!("admin::{}", method.to_lowercase()),
source: autumn_web::route_listing::RouteSource::User, middleware: vec![],
})
.collect()
}
#[cfg(test)]
mod conformance_tests {
use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
use autumn_web::route_listing::{RouteInfo, RouteSource};
const PLUGIN_NAME: &str = "autumn-admin-plugin";
fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
super::admin_route_infos(prefix)
.into_iter()
.map(|mut r| {
r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
r
})
.collect()
}
#[test]
fn admin_plugin_routes_are_attributed_to_plugin_name() {
let routes = admin_routes("/admin");
let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Pass,
"route attribution failed: {}",
result.message
);
}
#[test]
fn admin_plugin_routes_live_under_admin_prefix() {
let routes = admin_routes("/admin");
let result =
autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Pass,
"prefix check failed: {}\n{:?}",
result.message,
result.diagnostics
);
}
#[test]
fn admin_plugin_declares_builtin_job_routes() {
let routes = admin_routes("/admin");
let declared: std::collections::HashSet<(&str, &str)> = routes
.iter()
.map(|route| (route.method.as_str(), route.path.as_str()))
.collect();
for (method, path) in [
("GET", "/admin/jobs"),
("GET", "/admin/jobs/counters"),
("POST", "/admin/jobs/{id}/retry"),
("POST", "/admin/jobs/{id}/discard"),
("POST", "/admin/jobs/{id}/cancel"),
] {
assert!(
declared.contains(&(method, path)),
"missing declared admin job route {method} {path}"
);
}
}
#[test]
fn admin_plugin_has_no_route_collisions_in_isolation() {
let routes = admin_routes("/admin");
let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Pass,
"unexpected collision: {}\n{:?}",
result.message,
result.diagnostics
);
}
#[test]
fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
let routes = admin_routes("/admin");
let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
path_pattern: "/admin".to_owned(),
auth_mechanism: "Role: admin required via AdminPlugin::require_role \
(default) or AdminPlugin::require_role(None) to disable"
.to_owned(),
}];
let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
PLUGIN_NAME,
&routes,
&declared,
);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Pass,
"sensitive-surfaces check failed: {}",
result.message
);
}
#[test]
fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
let routes = admin_routes("/admin");
let result =
autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Fail,
"expected FAIL when sensitive routes are undeclared"
);
}
#[test]
fn admin_plugin_passes_full_conformance_with_config() {
let routes = admin_routes("/admin");
let config = ConformanceConfig::new(PLUGIN_NAME)
.prefix("/admin")
.sensitive_route(
"/admin",
"Role: admin required via AdminPlugin::require_role",
);
let report = run_conformance(&config, &routes);
assert!(
report.passed(),
"AdminPlugin conformance failed:\n{}",
report.to_text_report()
);
}
#[test]
fn admin_plugin_collision_with_host_route_detected() {
let mut routes = admin_routes("/admin");
routes.push(RouteInfo {
method: "GET".to_owned(),
path: "/admin".to_owned(),
handler: "host::admin_redirect".to_owned(),
source: RouteSource::User,
middleware: vec![],
});
let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Fail,
"expected collision to be detected"
);
let diag = &diagnostics[0];
assert_eq!(diag.method, "GET");
assert_eq!(diag.path, "/admin");
let sources: Vec<&str> = diag
.contributors
.iter()
.map(|c| c.source.as_str())
.collect();
assert!(
sources.contains(&"user"),
"missing user contributor: {sources:?}"
);
assert!(
sources.contains(&"plugin:autumn-admin-plugin"),
"missing plugin contributor: {sources:?}"
);
}
#[test]
fn admin_plugin_custom_prefix_passes_conformance() {
let routes = admin_routes("/backend");
let config = ConformanceConfig::new(PLUGIN_NAME)
.prefix("/backend")
.sensitive_route(
"/backend",
"Role: admin required via AdminPlugin::require_role",
);
let report = run_conformance(&config, &routes);
assert!(
report.passed(),
"AdminPlugin with custom prefix failed conformance:\n{}",
report.to_text_report()
);
}
#[test]
fn admin_plugin_double_registration_detected() {
let mut routes = admin_routes("/admin");
routes.extend(admin_routes("/admin"));
let result =
autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Fail,
"expected duplicate-registration FAIL when plugin installed twice"
);
}
#[test]
fn admin_plugin_single_registration_passes_duplicate_check() {
let routes = admin_routes("/admin");
let result =
autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
assert_eq!(
result.status,
autumn_web::plugin_conformance::CheckStatus::Pass,
"single registration should pass: {}",
result.message
);
}
}