1mod auth;
36mod registry;
37mod routes;
38mod templates;
39mod traits;
40
41pub use registry::AdminRegistry;
42pub use traits::{
43 AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams,
44 ListResult, SortDirection,
45};
46
47pub mod prelude {
49 pub use crate::{
50 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
51 SortDirection,
52 };
53}
54
55use std::borrow::Cow;
56use std::sync::Arc;
57
58use autumn_web::app::AppBuilder;
59use autumn_web::plugin::Plugin;
60use autumn_web::route_listing::RouteInfo;
61
62pub struct AdminPlugin {
67 registry: AdminRegistry,
68 prefix: String,
69 actuator_prefix: String,
70 auth_session_key: String,
71 require_role: Option<String>,
72}
73
74impl AdminPlugin {
75 #[must_use]
82 pub fn new() -> Self {
83 Self {
84 registry: AdminRegistry::new(),
85 prefix: "/admin".to_owned(),
86 actuator_prefix: "/actuator".to_owned(),
87 auth_session_key: "user_id".to_owned(),
88 require_role: Some("admin".to_owned()),
89 }
90 }
91
92 #[must_use]
94 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
95 self.prefix = prefix.into();
96 self
97 }
98
99 #[must_use]
104 pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
105 self.actuator_prefix = prefix.into();
106 self
107 }
108
109 #[must_use]
118 pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
119 self.auth_session_key = key.into();
120 self
121 }
122
123 #[must_use]
129 pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
130 self.require_role = role.into();
131 self
132 }
133
134 #[must_use]
139 pub fn register<M: AdminModel>(mut self, model: M) -> Self {
140 self.registry.register(model);
141 self
142 }
143}
144
145impl Default for AdminPlugin {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl Plugin for AdminPlugin {
152 fn name(&self) -> Cow<'static, str> {
153 Cow::Borrowed("autumn-admin-plugin")
154 }
155
156 fn build(self, app: AppBuilder) -> AppBuilder {
157 let Self {
158 registry,
159 prefix,
160 actuator_prefix,
161 auth_session_key,
162 require_role,
163 } = self;
164 let registry = Arc::new(registry);
165 let router = routes::admin_router(
166 Arc::clone(®istry),
167 &prefix,
168 actuator_prefix.clone(),
169 auth_session_key.clone(),
170 require_role.clone(),
171 );
172
173 tracing::info!(
174 prefix = %prefix,
175 actuator_prefix = %actuator_prefix,
176 auth_session_key = %auth_session_key,
177 models = registry.model_count(),
178 role = require_role.as_deref().unwrap_or("<none>"),
179 "๐ Autumn Admin mounted"
180 );
181
182 let declared = admin_route_infos(&prefix);
186
187 app.nest(&prefix, router).declare_plugin_routes(declared)
188 }
189}
190
191pub(crate) fn admin_route_infos(prefix: &str) -> Vec<RouteInfo> {
196 [
197 ("GET", prefix.to_string()),
198 ("GET", format!("{prefix}/jobs")),
199 ("GET", format!("{prefix}/jobs/counters")),
200 ("POST", format!("{prefix}/jobs/{{id}}/retry")),
201 ("POST", format!("{prefix}/jobs/{{id}}/discard")),
202 ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
203 ("GET", format!("{prefix}/{{slug}}")),
204 ("POST", format!("{prefix}/{{slug}}")),
205 ("GET", format!("{prefix}/{{slug}}/new")),
206 ("GET", format!("{prefix}/{{slug}}/{{id}}")),
207 ("POST", format!("{prefix}/{{slug}}/{{id}}")),
208 ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
209 ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
210 ("POST", format!("{prefix}/{{slug}}/actions")),
211 ("GET", format!("{prefix}{}", &*routes::ADMIN_JS_PATH)),
212 ]
213 .into_iter()
214 .map(|(method, path)| RouteInfo {
215 method: method.to_owned(),
216 path,
217 handler: format!("admin::{}", method.to_lowercase()),
218 source: autumn_web::route_listing::RouteSource::User, middleware: vec![],
220 })
221 .collect()
222}
223
224#[cfg(test)]
235mod conformance_tests {
236 use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
237 use autumn_web::route_listing::{RouteInfo, RouteSource};
238
239 const PLUGIN_NAME: &str = "autumn-admin-plugin";
240
241 fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
245 super::admin_route_infos(prefix)
246 .into_iter()
247 .map(|mut r| {
248 r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
249 r
250 })
251 .collect()
252 }
253
254 #[test]
255 fn admin_plugin_routes_are_attributed_to_plugin_name() {
256 let routes = admin_routes("/admin");
257 let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
258 assert_eq!(
259 result.status,
260 autumn_web::plugin_conformance::CheckStatus::Pass,
261 "route attribution failed: {}",
262 result.message
263 );
264 }
265
266 #[test]
267 fn admin_plugin_routes_live_under_admin_prefix() {
268 let routes = admin_routes("/admin");
269 let result =
270 autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
271 assert_eq!(
272 result.status,
273 autumn_web::plugin_conformance::CheckStatus::Pass,
274 "prefix check failed: {}\n{:?}",
275 result.message,
276 result.diagnostics
277 );
278 }
279
280 #[test]
281 fn admin_plugin_declares_builtin_job_routes() {
282 let routes = admin_routes("/admin");
283 let declared: std::collections::HashSet<(&str, &str)> = routes
284 .iter()
285 .map(|route| (route.method.as_str(), route.path.as_str()))
286 .collect();
287
288 for (method, path) in [
289 ("GET", "/admin/jobs"),
290 ("GET", "/admin/jobs/counters"),
291 ("POST", "/admin/jobs/{id}/retry"),
292 ("POST", "/admin/jobs/{id}/discard"),
293 ("POST", "/admin/jobs/{id}/cancel"),
294 ] {
295 assert!(
296 declared.contains(&(method, path)),
297 "missing declared admin job route {method} {path}"
298 );
299 }
300 }
301
302 #[test]
303 fn admin_plugin_has_no_route_collisions_in_isolation() {
304 let routes = admin_routes("/admin");
305 let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
306 assert_eq!(
307 result.status,
308 autumn_web::plugin_conformance::CheckStatus::Pass,
309 "unexpected collision: {}\n{:?}",
310 result.message,
311 result.diagnostics
312 );
313 }
314
315 #[test]
316 fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
317 let routes = admin_routes("/admin");
318 let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
319 path_pattern: "/admin".to_owned(),
320 auth_mechanism: "Role: admin required via AdminPlugin::require_role \
321 (default) or AdminPlugin::require_role(None) to disable"
322 .to_owned(),
323 }];
324 let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
325 PLUGIN_NAME,
326 &routes,
327 &declared,
328 );
329 assert_eq!(
330 result.status,
331 autumn_web::plugin_conformance::CheckStatus::Pass,
332 "sensitive-surfaces check failed: {}",
333 result.message
334 );
335 }
336
337 #[test]
338 fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
339 let routes = admin_routes("/admin");
340 let result =
341 autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
342 assert_eq!(
343 result.status,
344 autumn_web::plugin_conformance::CheckStatus::Fail,
345 "expected FAIL when sensitive routes are undeclared"
346 );
347 }
348
349 #[test]
350 fn admin_plugin_passes_full_conformance_with_config() {
351 let routes = admin_routes("/admin");
352 let config = ConformanceConfig::new(PLUGIN_NAME)
353 .prefix("/admin")
354 .sensitive_route(
355 "/admin",
356 "Role: admin required via AdminPlugin::require_role",
357 );
358 let report = run_conformance(&config, &routes);
359 assert!(
360 report.passed(),
361 "AdminPlugin conformance failed:\n{}",
362 report.to_text_report()
363 );
364 }
365
366 #[test]
367 fn admin_plugin_collision_with_host_route_detected() {
368 let mut routes = admin_routes("/admin");
369 routes.push(RouteInfo {
371 method: "GET".to_owned(),
372 path: "/admin".to_owned(),
373 handler: "host::admin_redirect".to_owned(),
374 source: RouteSource::User,
375 middleware: vec![],
376 });
377 let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
378 assert_eq!(
379 result.status,
380 autumn_web::plugin_conformance::CheckStatus::Fail,
381 "expected collision to be detected"
382 );
383 let diag = &diagnostics[0];
384 assert_eq!(diag.method, "GET");
385 assert_eq!(diag.path, "/admin");
386 let sources: Vec<&str> = diag
387 .contributors
388 .iter()
389 .map(|c| c.source.as_str())
390 .collect();
391 assert!(
392 sources.contains(&"user"),
393 "missing user contributor: {sources:?}"
394 );
395 assert!(
396 sources.contains(&"plugin:autumn-admin-plugin"),
397 "missing plugin contributor: {sources:?}"
398 );
399 }
400
401 #[test]
402 fn admin_plugin_custom_prefix_passes_conformance() {
403 let routes = admin_routes("/backend");
404 let config = ConformanceConfig::new(PLUGIN_NAME)
405 .prefix("/backend")
406 .sensitive_route(
407 "/backend",
408 "Role: admin required via AdminPlugin::require_role",
409 );
410 let report = run_conformance(&config, &routes);
411 assert!(
412 report.passed(),
413 "AdminPlugin with custom prefix failed conformance:\n{}",
414 report.to_text_report()
415 );
416 }
417
418 #[test]
419 fn admin_plugin_double_registration_detected() {
420 let mut routes = admin_routes("/admin");
422 routes.extend(admin_routes("/admin"));
423 let result =
424 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
425 assert_eq!(
426 result.status,
427 autumn_web::plugin_conformance::CheckStatus::Fail,
428 "expected duplicate-registration FAIL when plugin installed twice"
429 );
430 }
431
432 #[test]
433 fn admin_plugin_single_registration_passes_duplicate_check() {
434 let routes = admin_routes("/admin");
435 let result =
436 autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
437 assert_eq!(
438 result.status,
439 autumn_web::plugin_conformance::CheckStatus::Pass,
440 "single registration should pass: {}",
441 result.message
442 );
443 }
444}