Skip to main content

autumn_admin_plugin/
lib.rs

1//! # autumn-admin-plugin
2//!
3//! Out-of-the-box admin panel plugin for autumn-web applications.
4//!
5//! Provides auto-generated CRUD views, search, filtering, and audit trails
6//! for any model registered via the [`AdminPlugin`] builder. The UI is
7//! server-rendered with Maud + HTMX — no JS build step required.
8//!
9//! # Quick start
10//!
11//! ```rust,ignore
12//! use autumn_admin_plugin::{prelude::*, AdminPlugin};
13//!
14//! autumn_web::app()
15//!     .plugin(
16//!         AdminPlugin::new()
17//!             .register(ProjectAdmin::default())
18//!             .register(TicketAdmin::default()),
19//!     )
20//!     .routes(routes![...])
21//!     .run()
22//!     .await;
23//! ```
24//!
25//! # Security
26//!
27//! The plugin requires the `"admin"` role in the session by default. Override
28//! with [`AdminPlugin::require_role`] (pass `None` to disable; not recommended
29//! for production).
30//!
31//! # Naming convention
32//!
33//! First-party plugin: `autumn-<name>-plugin`.
34
35mod auth;
36pub mod experiments;
37pub mod feature_flags;
38mod impersonation;
39mod registry;
40mod routes;
41mod templates;
42pub mod tokens;
43mod traits;
44
45pub use impersonation::{AdminImpersonation, impersonation_banner_for};
46pub use registry::AdminRegistry;
47pub use templates::{IMPERSONATION_BANNER_CSS, ImpersonationBanner, impersonation_banner};
48pub use traits::{
49    AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry,
50    AdminHistoryPage, AdminImportError, AdminImportReport, AdminImportRowResult, AdminModel,
51    CsvImportMode, ListParams, ListResult, SelectOption, SortDirection,
52    dispatch_restore_purge_or_unhandled,
53};
54
55/// Common downstream imports for implementing admin models.
56pub mod prelude {
57    pub use crate::{
58        AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry, AdminHistoryPage,
59        AdminImportRowResult, AdminModel, CsvImportMode, ListParams, ListResult, SelectOption,
60        SortDirection, dispatch_restore_purge_or_unhandled,
61    };
62}
63
64use std::borrow::Cow;
65use std::sync::Arc;
66
67use autumn_web::app::AppBuilder;
68use autumn_web::auth::impersonation::ImpersonationGate;
69use autumn_web::plugin::Plugin;
70use autumn_web::route_listing::RouteInfo;
71use autumn_web::runtime_config::RuntimeConfigService;
72
73/// The admin panel plugin.
74///
75/// Register models via `.register()` and the plugin will mount a full admin
76/// UI under the configured prefix (default: `/admin`).
77pub struct AdminPlugin {
78    registry: AdminRegistry,
79    prefix: String,
80    actuator_prefix: String,
81    auth_session_key: String,
82    require_role: Option<String>,
83    runtime_config: Option<Arc<RuntimeConfigService>>,
84    /// When `true`, every mutating action (create, update, destroy) on any
85    /// registered model requires step-up authentication before proceeding.
86    ///
87    /// Enables this with [`AdminPlugin::with_step_up_mutations`].
88    step_up_mutations: bool,
89    /// Freshness window for step-up checks on admin mutations (seconds).
90    /// Defaults to [`autumn_web::step_up::DEFAULT_MAX_AGE_SECS`].
91    /// Override with [`AdminPlugin::with_step_up_max_age`].
92    step_up_max_age_secs: u64,
93    /// Authorization gate for user impersonation, installed by
94    /// [`AdminPlugin::with_impersonation`]. `None` (the default) leaves the
95    /// impersonation routes unmounted entirely.
96    impersonation: Option<ImpersonationGate>,
97}
98
99impl AdminPlugin {
100    /// Create a new admin plugin with default settings.
101    ///
102    /// Mounts at `/admin` and requires the `"admin"` role in the session.
103    /// Links to the actuator UI under `/actuator`. Reads the user
104    /// identifier from session key `"user_id"` (Autumn's default
105    /// `auth.session_key`).
106    #[must_use]
107    pub fn new() -> Self {
108        Self {
109            registry: AdminRegistry::new(),
110            prefix: "/admin".to_owned(),
111            actuator_prefix: "/actuator".to_owned(),
112            auth_session_key: "user_id".to_owned(),
113            require_role: Some("admin".to_owned()),
114            runtime_config: None,
115            step_up_mutations: false,
116            step_up_max_age_secs: autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
117            impersonation: None,
118        }
119    }
120
121    /// Override the URL prefix (default: `/admin`).
122    #[must_use]
123    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
124        self.prefix = prefix.into();
125        self
126    }
127
128    /// Override the actuator mount prefix that dashboard links/polling target
129    /// (default: `/actuator`). Must match `config.actuator.prefix` from your
130    /// autumn config — the plugin cannot read it automatically because config
131    /// is loaded after `Plugin::build` runs.
132    #[must_use]
133    pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
134        self.actuator_prefix = prefix.into();
135        self
136    }
137
138    /// Override the session key the role middleware reads to detect an
139    /// authenticated user. Default: `"user_id"`, matching Autumn's default
140    /// `auth.session_key`. Must match whatever your application populates
141    /// after login — e.g. set this to `"uid"` if you configured
142    /// `auth.session_key = "uid"`.
143    ///
144    /// The plugin can't read `config.auth.session_key` automatically
145    /// because config is loaded after `Plugin::build` runs.
146    #[must_use]
147    pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
148        self.auth_session_key = key.into();
149        self
150    }
151
152    /// Set the required session role for accessing the admin panel.
153    ///
154    /// Pass `None` to disable role checks entirely. Authentication
155    /// (a populated `user_id` session key) is always required when a role
156    /// is set.
157    #[must_use]
158    pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
159        self.require_role = role.into();
160        self
161    }
162
163    /// Register a model for admin management.
164    ///
165    /// The model must implement [`AdminModel`], which provides field metadata,
166    /// CRUD operations, and display configuration.
167    #[must_use]
168    pub fn register<M: AdminModel>(mut self, model: M) -> Self {
169        self.registry.register(model);
170        self
171    }
172
173    /// Enable the runtime config management page.
174    ///
175    /// Mounts `GET /config`, `POST /config/{key}/set`, `POST /config/{key}/unset`,
176    /// and `GET /config/{key}/history` under the admin prefix, and adds a
177    /// "Runtime Config" item to the sidebar navigation.
178    #[must_use]
179    pub fn with_runtime_config(mut self, svc: Arc<RuntimeConfigService>) -> Self {
180        self.runtime_config = Some(svc);
181        self
182    }
183
184    /// Require step-up (fresh) authentication before any mutating admin action.
185    ///
186    /// When enabled, every `POST` (create/update) and `DELETE` (destroy) request
187    /// to the admin panel is checked against the session's `last_strong_auth_at`
188    /// claim using the global step-up max-age configured in `[auth.step_up]`
189    /// (default: 5 minutes). Requests without a valid fresh-auth claim are
190    /// redirected to `/reauth?return_to=…` (HTML clients) or receive a
191    /// `401 step_up_required` problem-details response (JSON clients).
192    ///
193    /// Highly recommended for production admin panels to reduce the blast radius
194    /// of a hijacked admin session.
195    ///
196    /// # Example
197    ///
198    /// ```rust,ignore
199    /// AdminPlugin::new()
200    ///     .register(UserAdmin::default())
201    ///     .with_step_up_mutations()
202    /// ```
203    #[must_use]
204    pub const fn with_step_up_mutations(mut self) -> Self {
205        self.step_up_mutations = true;
206        self
207    }
208
209    /// Override the step-up freshness window for admin mutations.
210    ///
211    /// Only meaningful when [`with_step_up_mutations`](Self::with_step_up_mutations)
212    /// is also called. Calls `with_step_up_mutations` implicitly.
213    ///
214    /// # Example
215    ///
216    /// ```rust,ignore
217    /// AdminPlugin::new()
218    ///     .register(UserAdmin::default())
219    ///     .with_step_up_max_age(600) // 10-minute window
220    /// ```
221    #[must_use]
222    pub const fn with_step_up_max_age(mut self, secs: u64) -> Self {
223        self.step_up_mutations = true;
224        self.step_up_max_age_secs = secs;
225        self
226    }
227
228    /// Enable **user impersonation** ("log in as this user") for this admin
229    /// panel, gated by `gate` (issue #1394).
230    ///
231    /// Opt-in in two senses. Without this call the impersonation routes are not
232    /// mounted at all, *and* the core primitive
233    /// ([`autumn_web::auth::impersonation`]) default-denies — so an app can
234    /// never acquire impersonation by accident. With it, two routes appear:
235    ///
236    /// | Route | Guard |
237    /// |---|---|
238    /// | `POST {prefix}/impersonate` (body: `user_id`) | admin role + step-up (if enabled) + `gate` |
239    /// | `POST {prefix}/impersonate/stop` | none — reverting must always work |
240    ///
241    /// Every admin page then renders the persistent "Viewing as … — Stop
242    /// impersonating" banner. Put the same banner in your **application**
243    /// layout with [`impersonation_banner_for`] so it is visible on the pages
244    /// the operator is actually looking at.
245    ///
246    /// **Requires an audit sink.** `begin_impersonation` refuses with `500`
247    /// unless the app has an [`AuditLogger`](autumn_web::audit::AuditLogger)
248    /// carrying at least one sink, because an unrecorded identity swap is the
249    /// exact failure this feature exists to prevent. The plugin logs an error at
250    /// startup when the sink is missing.
251    ///
252    /// The gate is also where an app enforces its own boundaries — in
253    /// particular tenancy, which the framework cannot infer: the policy
254    /// receives the full [`PolicyContext`](autumn_web::authorization::PolicyContext),
255    /// session and DB pool included.
256    ///
257    /// # Example
258    ///
259    /// ```rust,ignore
260    /// use autumn_web::auth::impersonation::ImpersonationGate;
261    ///
262    /// AdminPlugin::new()
263    ///     .register(UserAdmin::default())
264    ///     .with_impersonation(ImpersonationGate::allow_roles(["admin"]))
265    /// ```
266    #[must_use]
267    pub fn with_impersonation(mut self, gate: ImpersonationGate) -> Self {
268        self.impersonation = Some(gate);
269        self
270    }
271}
272
273impl Default for AdminPlugin {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl Plugin for AdminPlugin {
280    /// This plugin ships in lockstep with `autumn-web` — see
281    /// [`lockstep_contract`](autumn_web::plugin_contract::lockstep_contract).
282    fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
283        Some(autumn_web::plugin_contract::lockstep_contract(
284            env!("CARGO_PKG_NAME"),
285            env!("CARGO_PKG_VERSION"),
286        ))
287    }
288
289    fn name(&self) -> Cow<'static, str> {
290        Cow::Borrowed("autumn-admin-plugin")
291    }
292
293    fn build(self, app: AppBuilder) -> AppBuilder {
294        let Self {
295            registry,
296            prefix,
297            actuator_prefix,
298            auth_session_key,
299            require_role,
300            runtime_config,
301            step_up_mutations,
302            step_up_max_age_secs,
303            impersonation,
304        } = self;
305        let has_config = runtime_config.is_some();
306        // "config" slug only conflicts when the runtime-config routes are mounted.
307        assert!(
308            !(has_config && registry.get("config").is_some()),
309            "autumn-admin: model slug 'config' conflicts with the mounted runtime-config \
310             routes; rename the model or don't call with_runtime_config",
311        );
312        // Same hazard the "config" guard above covers: `POST {prefix}/impersonate`
313        // is a literal route, so a model registered at that slug would silently
314        // lose its create endpoint to the impersonation handler.
315        assert!(
316            !(impersonation.is_some() && registry.get("impersonate").is_some()),
317            "autumn-admin: model slug 'impersonate' conflicts with the mounted \
318             impersonation routes; rename the model or don't call with_impersonation",
319        );
320        let registry = Arc::new(registry);
321        let router = routes::admin_router(
322            Arc::clone(&registry),
323            &prefix,
324            actuator_prefix.clone(),
325            routes::AdminGuards {
326                auth_session_key: auth_session_key.clone(),
327                require_role: require_role.clone(),
328                step_up_mutations,
329                step_up_max_age_secs,
330            },
331            runtime_config,
332            impersonation.is_some(),
333        );
334
335        tracing::info!(
336            prefix = %prefix,
337            actuator_prefix = %actuator_prefix,
338            auth_session_key = %auth_session_key,
339            models = registry.model_count(),
340            role = require_role.as_deref().unwrap_or("<none>"),
341            step_up_mutations = %step_up_mutations,
342            "🍂 Autumn Admin mounted"
343        );
344
345        // Declare routes for `autumn routes` listing. The underlying Axum router
346        // is added via nest() which is opaque to route enumeration, so we
347        // explicitly register route metadata here. Because the declared routes'
348        // paths all fall under the nest prefix, `autumn routes audit` treats this
349        // mount as covered (enumerable) instead of an omitted, unprovable raw
350        // router that would false-fail the gate.
351        let declared = admin_route_infos(
352            &prefix,
353            has_config,
354            require_role.as_deref(),
355            impersonation.is_some(),
356        );
357
358        let app = match impersonation {
359            // Publish the gate so the core primitive can find it. Registered
360            // here rather than asked of the application, so `with_impersonation`
361            // is the single opt-in.
362            // `impersonation_gate` registers the gate and reports a
363            // self-destructive `auth.session_key` at boot; the audit-sink check
364            // below is the plugin's own addition.
365            Some(gate) => app.impersonation_gate(gate).state_initializer(|state| {
366                // Surface the audit requirement at startup rather than at the
367                // first impersonation attempt: `begin_impersonation` refuses
368                // with 500 without a sink, and a 500 in front of a support
369                // engineer is a worse place to learn this than a boot log.
370                let audited = state
371                    .extension::<autumn_web::audit::AuditLogger>()
372                    .is_some_and(|logger| logger.is_enabled());
373                if !audited {
374                    tracing::error!(
375                        "🍂 Autumn Admin: impersonation is enabled but no audit sink is \
376                         configured; every attempt will be refused. Register one with \
377                         `AppBuilder::with_audit_sink(...)`."
378                    );
379                }
380            }),
381            None => app,
382        };
383
384        app.nest(&prefix, router)
385            .declare_plugin_routes(declared)
386            .plugin_assets(&routes::ASSETS)
387    }
388}
389
390/// Generate the route metadata list for this plugin's mounted routes.
391///
392/// `has_config` must match whether `with_runtime_config` was called; config
393/// routes are only mounted when a `RuntimeConfigService` is provided, so
394/// including them unconditionally would produce false route-collision signals.
395///
396/// Kept in sync with `routes::admin_router` — update here when routes are
397/// added or removed from the admin router.
398///
399/// `require_role` mirrors [`AdminPlugin::require_role`]: the whole admin router
400/// is wrapped in role-check middleware, so every declared route inherits that
401/// posture. When a role is required the routes classify [`Gated`] (carrying the
402/// role) rather than `Unclassified`; when the plugin's auth is explicitly
403/// disabled (`None`) they classify [`Public`]. Without this, the default admin
404/// plugin would false-fail `autumn routes audit` out of the box, since declared
405/// routes otherwise inherit `Unclassified` from `RouteInfo::default()`.
406///
407/// [`Gated`]: autumn_web::route_listing::RouteClassification::Gated
408/// [`Public`]: autumn_web::route_listing::RouteClassification::Public
409pub(crate) fn admin_route_infos(
410    prefix: &str,
411    has_config: bool,
412    require_role: Option<&str>,
413    has_impersonation: bool,
414) -> Vec<RouteInfo> {
415    use autumn_web::route_listing::RouteClassification;
416
417    let (classification, roles) = require_role.map_or_else(
418        || (RouteClassification::Public, Vec::new()),
419        |role| (RouteClassification::Gated, vec![role.to_owned()]),
420    );
421    let mut entries: Vec<(&str, String)> = vec![
422        ("GET", prefix.to_string()),
423        ("GET", format!("{prefix}/jobs")),
424        ("GET", format!("{prefix}/jobs/counters")),
425        ("POST", format!("{prefix}/jobs/{{id}}/retry")),
426        ("POST", format!("{prefix}/jobs/{{id}}/discard")),
427        ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
428    ];
429    if has_config {
430        entries.extend([
431            ("GET", format!("{prefix}/config")),
432            ("POST", format!("{prefix}/config/{{key}}/set")),
433            ("POST", format!("{prefix}/config/{{key}}/unset")),
434            ("GET", format!("{prefix}/config/{{key}}/history")),
435        ]);
436    }
437    if has_impersonation {
438        entries.push(("POST", format!("{prefix}/impersonate")));
439    }
440    entries.extend([
441        ("GET", format!("{prefix}/{{slug}}")),
442        ("POST", format!("{prefix}/{{slug}}")),
443        ("GET", format!("{prefix}/{{slug}}/new")),
444        ("GET", format!("{prefix}/{{slug}}/export.csv")),
445        ("GET", format!("{prefix}/{{slug}}/import")),
446        ("POST", format!("{prefix}/{{slug}}/import")),
447        ("GET", format!("{prefix}/{{slug}}/{{id}}")),
448        ("POST", format!("{prefix}/{{slug}}/{{id}}")),
449        ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
450        ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
451        ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
452        ("POST", format!("{prefix}/{{slug}}/actions")),
453    ]);
454    // The revert route is intentionally ungated (see `routes::admin_router`),
455    // so it is declared separately as `Public` rather than inheriting the admin
456    // role — declaring it `Gated` would make the route audit assert a guard
457    // that genuinely is not there.
458    let ungated: Vec<(&str, String)> = if has_impersonation {
459        vec![("POST", format!("{prefix}/impersonate/stop"))]
460    } else {
461        Vec::new()
462    };
463    entries
464        .into_iter()
465        .map(|(method, path)| RouteInfo {
466            method: method.to_owned(),
467            path,
468            handler: format!("admin::{}", method.to_lowercase()),
469            source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
470            classification,
471            roles: roles.clone(),
472            ..Default::default()
473        })
474        .chain(ungated.into_iter().map(|(method, path)| RouteInfo {
475            method: method.to_owned(),
476            path,
477            handler: format!("admin::{}", method.to_lowercase()),
478            source: autumn_web::route_listing::RouteSource::User,
479            classification: RouteClassification::Public,
480            roles: Vec::new(),
481            ..Default::default()
482        }))
483        .collect()
484}
485
486#[cfg(test)]
487mod contract_tests {
488    use super::AdminPlugin;
489    use autumn_web::plugin::Plugin;
490
491    #[test]
492    fn contract_declares_lockstep_with_own_crate() {
493        let contract = AdminPlugin::new().contract().expect("a contract");
494        assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
495        assert_eq!(
496            contract.plugin_version.as_deref(),
497            Some(env!("CARGO_PKG_VERSION"))
498        );
499        assert_eq!(
500            contract.autumn_web.as_deref(),
501            Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
502        );
503        assert!(contract.experimental_surfaces.is_empty());
504    }
505}
506
507// ── Conformance reference tests ────────────────────────────────────────────
508//
509// These tests are the reference example for the Autumn plugin conformance
510// workflow documented in docs/plugins.md.  They use
511// `autumn_web::plugin_conformance` to verify that the admin plugin's declared
512// routes satisfy all conformance checks before publication.
513//
514// See docs/plugins.md § "Plugin conformance and publishing checklist" for the
515// equivalent `autumn plugin-check` CLI invocation.
516
517#[cfg(test)]
518mod conformance_tests {
519    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
520    use autumn_web::route_listing::{RouteInfo, RouteSource};
521
522    const PLUGIN_NAME: &str = "autumn-admin-plugin";
523
524    /// Build the routes that `AdminPlugin` contributes under `prefix`,
525    /// attributed to the plugin. Reuses `admin_route_infos` from the outer
526    /// module and overrides the source to `Plugin(PLUGIN_NAME)`.
527    fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
528        admin_routes_with_config(prefix, true)
529    }
530
531    fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
532        super::admin_route_infos(prefix, has_config, Some("admin"), true)
533            .into_iter()
534            .map(|mut r| {
535                r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
536                r
537            })
538            .collect()
539    }
540
541    #[test]
542    fn admin_plugin_routes_are_attributed_to_plugin_name() {
543        let routes = admin_routes("/admin");
544        let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
545        assert_eq!(
546            result.status,
547            autumn_web::plugin_conformance::CheckStatus::Pass,
548            "route attribution failed: {}",
549            result.message
550        );
551    }
552
553    #[test]
554    fn admin_plugin_routes_live_under_admin_prefix() {
555        let routes = admin_routes("/admin");
556        let result =
557            autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
558        assert_eq!(
559            result.status,
560            autumn_web::plugin_conformance::CheckStatus::Pass,
561            "prefix check failed: {}\n{:?}",
562            result.message,
563            result.diagnostics
564        );
565    }
566
567    #[test]
568    fn admin_plugin_declares_builtin_job_routes() {
569        let routes = admin_routes("/admin");
570        let declared: std::collections::HashSet<(&str, &str)> = routes
571            .iter()
572            .map(|route| (route.method.as_str(), route.path.as_str()))
573            .collect();
574
575        for (method, path) in [
576            ("GET", "/admin/jobs"),
577            ("GET", "/admin/jobs/counters"),
578            ("POST", "/admin/jobs/{id}/retry"),
579            ("POST", "/admin/jobs/{id}/discard"),
580            ("POST", "/admin/jobs/{id}/cancel"),
581        ] {
582            assert!(
583                declared.contains(&(method, path)),
584                "missing declared admin job route {method} {path}"
585            );
586        }
587    }
588
589    #[test]
590    fn admin_plugin_declares_builtin_config_routes_when_enabled() {
591        let routes = admin_routes_with_config("/admin", true);
592        let declared: std::collections::HashSet<(&str, &str)> = routes
593            .iter()
594            .map(|route| (route.method.as_str(), route.path.as_str()))
595            .collect();
596
597        for (method, path) in [
598            ("GET", "/admin/config"),
599            ("POST", "/admin/config/{key}/set"),
600            ("POST", "/admin/config/{key}/unset"),
601            ("GET", "/admin/config/{key}/history"),
602        ] {
603            assert!(
604                declared.contains(&(method, path)),
605                "missing declared admin config route {method} {path}"
606            );
607        }
608    }
609
610    #[test]
611    fn admin_plugin_omits_config_routes_when_disabled() {
612        let routes = admin_routes_with_config("/admin", false);
613        let declared: std::collections::HashSet<(&str, &str)> = routes
614            .iter()
615            .map(|route| (route.method.as_str(), route.path.as_str()))
616            .collect();
617
618        for (method, path) in [
619            ("GET", "/admin/config"),
620            ("POST", "/admin/config/{key}/set"),
621            ("POST", "/admin/config/{key}/unset"),
622            ("GET", "/admin/config/{key}/history"),
623        ] {
624            assert!(
625                !declared.contains(&(method, path)),
626                "config route {method} {path} should not be declared when has_config=false"
627            );
628        }
629    }
630
631    /// The default admin plugin guards its whole router with role-check
632    /// middleware, so its declared routes must classify `Gated` (carrying the
633    /// role) — not `Unclassified` — or `autumn routes audit` would false-fail
634    /// every protected admin route out of the box (#1604).
635    #[test]
636    fn admin_plugin_declared_routes_classify_gated_with_role() {
637        use autumn_web::route_listing::RouteClassification;
638
639        let routes = super::admin_route_infos("/admin", true, Some("admin"), false);
640        assert!(!routes.is_empty());
641        for r in &routes {
642            assert_eq!(
643                r.classification,
644                RouteClassification::Gated,
645                "admin route {} {} should be Gated, got {:?}",
646                r.method,
647                r.path,
648                r.classification
649            );
650            assert_eq!(
651                r.roles,
652                vec!["admin".to_owned()],
653                "admin route {} {} should carry the required role",
654                r.method,
655                r.path
656            );
657        }
658    }
659
660    /// The impersonation routes are declared only when
661    /// [`AdminPlugin::with_impersonation`] was called, and the revert route is
662    /// declared `Public` because it is deliberately mounted outside the role
663    /// gate (see `routes::admin_router`) — declaring it `Gated` would make the
664    /// route audit assert a guard that is not there.
665    #[test]
666    fn impersonation_routes_are_declared_only_when_enabled() {
667        use autumn_web::route_listing::RouteClassification;
668
669        let off = super::admin_route_infos("/admin", false, Some("admin"), false);
670        assert!(
671            !off.iter().any(|r| r.path.contains("/impersonate")),
672            "no impersonation routes without the opt-in"
673        );
674
675        let on = super::admin_route_infos("/admin", false, Some("admin"), true);
676        let begin = on
677            .iter()
678            .find(|r| r.path == "/admin/impersonate")
679            .expect("begin route declared");
680        assert_eq!(begin.method, "POST");
681        assert_eq!(begin.classification, RouteClassification::Gated);
682        assert_eq!(begin.roles, vec!["admin".to_owned()]);
683
684        let stop = on
685            .iter()
686            .find(|r| r.path == "/admin/impersonate/stop")
687            .expect("revert route declared");
688        assert_eq!(stop.method, "POST");
689        assert_eq!(
690            stop.classification,
691            RouteClassification::Public,
692            "the revert route is ungated on purpose"
693        );
694        assert!(stop.roles.is_empty());
695    }
696
697    /// When the plugin's auth is explicitly disabled (`require_role(None)`) the
698    /// declared routes classify `Public` — still a proven posture, so the audit
699    /// gate passes rather than flagging them `Unclassified`.
700    #[test]
701    fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
702        use autumn_web::route_listing::RouteClassification;
703
704        let routes = super::admin_route_infos("/admin", true, None, false);
705        assert!(!routes.is_empty());
706        for r in &routes {
707            assert_eq!(
708                r.classification,
709                RouteClassification::Public,
710                "admin route {} {} should be Public when auth disabled",
711                r.method,
712                r.path
713            );
714            assert!(r.roles.is_empty(), "public route should carry no roles");
715        }
716    }
717
718    #[test]
719    fn admin_plugin_has_no_route_collisions_in_isolation() {
720        let routes = admin_routes("/admin");
721        let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
722        assert_eq!(
723            result.status,
724            autumn_web::plugin_conformance::CheckStatus::Pass,
725            "unexpected collision: {}\n{:?}",
726            result.message,
727            result.diagnostics
728        );
729    }
730
731    #[test]
732    fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
733        let routes = admin_routes("/admin");
734        let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
735            path_pattern: "/admin".to_owned(),
736            auth_mechanism: "Role: admin required via AdminPlugin::require_role \
737                             (default) or AdminPlugin::require_role(None) to disable"
738                .to_owned(),
739        }];
740        let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
741            PLUGIN_NAME,
742            &routes,
743            &declared,
744        );
745        assert_eq!(
746            result.status,
747            autumn_web::plugin_conformance::CheckStatus::Pass,
748            "sensitive-surfaces check failed: {}",
749            result.message
750        );
751    }
752
753    #[test]
754    fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
755        let routes = admin_routes("/admin");
756        let result =
757            autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
758        assert_eq!(
759            result.status,
760            autumn_web::plugin_conformance::CheckStatus::Fail,
761            "expected FAIL when sensitive routes are undeclared"
762        );
763    }
764
765    #[test]
766    fn admin_plugin_passes_full_conformance_with_config() {
767        let routes = admin_routes("/admin");
768        let config = ConformanceConfig::new(PLUGIN_NAME)
769            .prefix("/admin")
770            .sensitive_route(
771                "/admin",
772                "Role: admin required via AdminPlugin::require_role",
773            );
774        let report = run_conformance(&config, &routes);
775        assert!(
776            report.passed(),
777            "AdminPlugin conformance failed:\n{}",
778            report.to_text_report()
779        );
780    }
781
782    #[test]
783    fn admin_plugin_collision_with_host_route_detected() {
784        let mut routes = admin_routes("/admin");
785        // Simulate a host app that accidentally defines GET /admin
786        routes.push(RouteInfo {
787            method: "GET".to_owned(),
788            path: "/admin".to_owned(),
789            handler: "host::admin_redirect".to_owned(),
790            source: RouteSource::User,
791            ..Default::default()
792        });
793        let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
794        assert_eq!(
795            result.status,
796            autumn_web::plugin_conformance::CheckStatus::Fail,
797            "expected collision to be detected"
798        );
799        let diag = &diagnostics[0];
800        assert_eq!(diag.method, "GET");
801        assert_eq!(diag.path, "/admin");
802        let sources: Vec<&str> = diag
803            .contributors
804            .iter()
805            .map(|c| c.source.as_str())
806            .collect();
807        assert!(
808            sources.contains(&"user"),
809            "missing user contributor: {sources:?}"
810        );
811        assert!(
812            sources.contains(&"plugin:autumn-admin-plugin"),
813            "missing plugin contributor: {sources:?}"
814        );
815    }
816
817    #[test]
818    fn admin_plugin_custom_prefix_passes_conformance() {
819        let routes = admin_routes("/backend");
820        let config = ConformanceConfig::new(PLUGIN_NAME)
821            .prefix("/backend")
822            .sensitive_route(
823                "/backend",
824                "Role: admin required via AdminPlugin::require_role",
825            );
826        let report = run_conformance(&config, &routes);
827        assert!(
828            report.passed(),
829            "AdminPlugin with custom prefix failed conformance:\n{}",
830            report.to_text_report()
831        );
832    }
833
834    #[test]
835    fn admin_plugin_double_registration_detected() {
836        // Simulate registering the admin plugin twice — its routes appear twice.
837        let mut routes = admin_routes("/admin");
838        routes.extend(admin_routes("/admin"));
839        let result =
840            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
841        assert_eq!(
842            result.status,
843            autumn_web::plugin_conformance::CheckStatus::Fail,
844            "expected duplicate-registration FAIL when plugin installed twice"
845        );
846    }
847
848    #[test]
849    fn admin_plugin_single_registration_passes_duplicate_check() {
850        let routes = admin_routes("/admin");
851        let result =
852            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
853        assert_eq!(
854            result.status,
855            autumn_web::plugin_conformance::CheckStatus::Pass,
856            "single registration should pass: {}",
857            result.message
858        );
859    }
860}