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            auth_session_key.clone(),
326            require_role.clone(),
327            runtime_config,
328            step_up_mutations,
329            step_up_max_age_secs,
330            impersonation.is_some(),
331        );
332
333        tracing::info!(
334            prefix = %prefix,
335            actuator_prefix = %actuator_prefix,
336            auth_session_key = %auth_session_key,
337            models = registry.model_count(),
338            role = require_role.as_deref().unwrap_or("<none>"),
339            step_up_mutations = %step_up_mutations,
340            "🍂 Autumn Admin mounted"
341        );
342
343        // Declare routes for `autumn routes` listing. The underlying Axum router
344        // is added via nest() which is opaque to route enumeration, so we
345        // explicitly register route metadata here. Because the declared routes'
346        // paths all fall under the nest prefix, `autumn routes audit` treats this
347        // mount as covered (enumerable) instead of an omitted, unprovable raw
348        // router that would false-fail the gate.
349        let declared = admin_route_infos(
350            &prefix,
351            has_config,
352            require_role.as_deref(),
353            impersonation.is_some(),
354        );
355
356        let app = match impersonation {
357            // Publish the gate so the core primitive can find it. Registered
358            // here rather than asked of the application, so `with_impersonation`
359            // is the single opt-in.
360            // `impersonation_gate` registers the gate and reports a
361            // self-destructive `auth.session_key` at boot; the audit-sink check
362            // below is the plugin's own addition.
363            Some(gate) => app.impersonation_gate(gate).state_initializer(|state| {
364                // Surface the audit requirement at startup rather than at the
365                // first impersonation attempt: `begin_impersonation` refuses
366                // with 500 without a sink, and a 500 in front of a support
367                // engineer is a worse place to learn this than a boot log.
368                let audited = state
369                    .extension::<autumn_web::audit::AuditLogger>()
370                    .is_some_and(|logger| logger.is_enabled());
371                if !audited {
372                    tracing::error!(
373                        "🍂 Autumn Admin: impersonation is enabled but no audit sink is \
374                         configured; every attempt will be refused. Register one with \
375                         `AppBuilder::with_audit_sink(...)`."
376                    );
377                }
378            }),
379            None => app,
380        };
381
382        app.nest(&prefix, router)
383            .declare_plugin_routes(declared)
384            .plugin_assets(&routes::ASSETS)
385    }
386}
387
388/// Generate the route metadata list for this plugin's mounted routes.
389///
390/// `has_config` must match whether `with_runtime_config` was called; config
391/// routes are only mounted when a `RuntimeConfigService` is provided, so
392/// including them unconditionally would produce false route-collision signals.
393///
394/// Kept in sync with `routes::admin_router` — update here when routes are
395/// added or removed from the admin router.
396///
397/// `require_role` mirrors [`AdminPlugin::require_role`]: the whole admin router
398/// is wrapped in role-check middleware, so every declared route inherits that
399/// posture. When a role is required the routes classify [`Gated`] (carrying the
400/// role) rather than `Unclassified`; when the plugin's auth is explicitly
401/// disabled (`None`) they classify [`Public`]. Without this, the default admin
402/// plugin would false-fail `autumn routes audit` out of the box, since declared
403/// routes otherwise inherit `Unclassified` from `RouteInfo::default()`.
404///
405/// [`Gated`]: autumn_web::route_listing::RouteClassification::Gated
406/// [`Public`]: autumn_web::route_listing::RouteClassification::Public
407pub(crate) fn admin_route_infos(
408    prefix: &str,
409    has_config: bool,
410    require_role: Option<&str>,
411    has_impersonation: bool,
412) -> Vec<RouteInfo> {
413    use autumn_web::route_listing::RouteClassification;
414
415    let (classification, roles) = require_role.map_or_else(
416        || (RouteClassification::Public, Vec::new()),
417        |role| (RouteClassification::Gated, vec![role.to_owned()]),
418    );
419    let mut entries: Vec<(&str, String)> = vec![
420        ("GET", prefix.to_string()),
421        ("GET", format!("{prefix}/jobs")),
422        ("GET", format!("{prefix}/jobs/counters")),
423        ("POST", format!("{prefix}/jobs/{{id}}/retry")),
424        ("POST", format!("{prefix}/jobs/{{id}}/discard")),
425        ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
426    ];
427    if has_config {
428        entries.extend([
429            ("GET", format!("{prefix}/config")),
430            ("POST", format!("{prefix}/config/{{key}}/set")),
431            ("POST", format!("{prefix}/config/{{key}}/unset")),
432            ("GET", format!("{prefix}/config/{{key}}/history")),
433        ]);
434    }
435    if has_impersonation {
436        entries.push(("POST", format!("{prefix}/impersonate")));
437    }
438    entries.extend([
439        ("GET", format!("{prefix}/{{slug}}")),
440        ("POST", format!("{prefix}/{{slug}}")),
441        ("GET", format!("{prefix}/{{slug}}/new")),
442        ("GET", format!("{prefix}/{{slug}}/export.csv")),
443        ("GET", format!("{prefix}/{{slug}}/import")),
444        ("POST", format!("{prefix}/{{slug}}/import")),
445        ("GET", format!("{prefix}/{{slug}}/{{id}}")),
446        ("POST", format!("{prefix}/{{slug}}/{{id}}")),
447        ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
448        ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
449        ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
450        ("POST", format!("{prefix}/{{slug}}/actions")),
451    ]);
452    // The revert route is intentionally ungated (see `routes::admin_router`),
453    // so it is declared separately as `Public` rather than inheriting the admin
454    // role — declaring it `Gated` would make the route audit assert a guard
455    // that genuinely is not there.
456    let ungated: Vec<(&str, String)> = if has_impersonation {
457        vec![("POST", format!("{prefix}/impersonate/stop"))]
458    } else {
459        Vec::new()
460    };
461    entries
462        .into_iter()
463        .map(|(method, path)| RouteInfo {
464            method: method.to_owned(),
465            path,
466            handler: format!("admin::{}", method.to_lowercase()),
467            source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
468            classification,
469            roles: roles.clone(),
470            ..Default::default()
471        })
472        .chain(ungated.into_iter().map(|(method, path)| RouteInfo {
473            method: method.to_owned(),
474            path,
475            handler: format!("admin::{}", method.to_lowercase()),
476            source: autumn_web::route_listing::RouteSource::User,
477            classification: RouteClassification::Public,
478            roles: Vec::new(),
479            ..Default::default()
480        }))
481        .collect()
482}
483
484#[cfg(test)]
485mod contract_tests {
486    use super::AdminPlugin;
487    use autumn_web::plugin::Plugin;
488
489    #[test]
490    fn contract_declares_lockstep_with_own_crate() {
491        let contract = AdminPlugin::new().contract().expect("a contract");
492        assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
493        assert_eq!(
494            contract.plugin_version.as_deref(),
495            Some(env!("CARGO_PKG_VERSION"))
496        );
497        assert_eq!(
498            contract.autumn_web.as_deref(),
499            Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
500        );
501        assert!(contract.experimental_surfaces.is_empty());
502    }
503}
504
505// ── Conformance reference tests ────────────────────────────────────────────
506//
507// These tests are the reference example for the Autumn plugin conformance
508// workflow documented in docs/plugins.md.  They use
509// `autumn_web::plugin_conformance` to verify that the admin plugin's declared
510// routes satisfy all conformance checks before publication.
511//
512// See docs/plugins.md § "Plugin conformance and publishing checklist" for the
513// equivalent `autumn plugin-check` CLI invocation.
514
515#[cfg(test)]
516mod conformance_tests {
517    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
518    use autumn_web::route_listing::{RouteInfo, RouteSource};
519
520    const PLUGIN_NAME: &str = "autumn-admin-plugin";
521
522    /// Build the routes that `AdminPlugin` contributes under `prefix`,
523    /// attributed to the plugin. Reuses `admin_route_infos` from the outer
524    /// module and overrides the source to `Plugin(PLUGIN_NAME)`.
525    fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
526        admin_routes_with_config(prefix, true)
527    }
528
529    fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
530        super::admin_route_infos(prefix, has_config, Some("admin"), true)
531            .into_iter()
532            .map(|mut r| {
533                r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
534                r
535            })
536            .collect()
537    }
538
539    #[test]
540    fn admin_plugin_routes_are_attributed_to_plugin_name() {
541        let routes = admin_routes("/admin");
542        let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
543        assert_eq!(
544            result.status,
545            autumn_web::plugin_conformance::CheckStatus::Pass,
546            "route attribution failed: {}",
547            result.message
548        );
549    }
550
551    #[test]
552    fn admin_plugin_routes_live_under_admin_prefix() {
553        let routes = admin_routes("/admin");
554        let result =
555            autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
556        assert_eq!(
557            result.status,
558            autumn_web::plugin_conformance::CheckStatus::Pass,
559            "prefix check failed: {}\n{:?}",
560            result.message,
561            result.diagnostics
562        );
563    }
564
565    #[test]
566    fn admin_plugin_declares_builtin_job_routes() {
567        let routes = admin_routes("/admin");
568        let declared: std::collections::HashSet<(&str, &str)> = routes
569            .iter()
570            .map(|route| (route.method.as_str(), route.path.as_str()))
571            .collect();
572
573        for (method, path) in [
574            ("GET", "/admin/jobs"),
575            ("GET", "/admin/jobs/counters"),
576            ("POST", "/admin/jobs/{id}/retry"),
577            ("POST", "/admin/jobs/{id}/discard"),
578            ("POST", "/admin/jobs/{id}/cancel"),
579        ] {
580            assert!(
581                declared.contains(&(method, path)),
582                "missing declared admin job route {method} {path}"
583            );
584        }
585    }
586
587    #[test]
588    fn admin_plugin_declares_builtin_config_routes_when_enabled() {
589        let routes = admin_routes_with_config("/admin", true);
590        let declared: std::collections::HashSet<(&str, &str)> = routes
591            .iter()
592            .map(|route| (route.method.as_str(), route.path.as_str()))
593            .collect();
594
595        for (method, path) in [
596            ("GET", "/admin/config"),
597            ("POST", "/admin/config/{key}/set"),
598            ("POST", "/admin/config/{key}/unset"),
599            ("GET", "/admin/config/{key}/history"),
600        ] {
601            assert!(
602                declared.contains(&(method, path)),
603                "missing declared admin config route {method} {path}"
604            );
605        }
606    }
607
608    #[test]
609    fn admin_plugin_omits_config_routes_when_disabled() {
610        let routes = admin_routes_with_config("/admin", false);
611        let declared: std::collections::HashSet<(&str, &str)> = routes
612            .iter()
613            .map(|route| (route.method.as_str(), route.path.as_str()))
614            .collect();
615
616        for (method, path) in [
617            ("GET", "/admin/config"),
618            ("POST", "/admin/config/{key}/set"),
619            ("POST", "/admin/config/{key}/unset"),
620            ("GET", "/admin/config/{key}/history"),
621        ] {
622            assert!(
623                !declared.contains(&(method, path)),
624                "config route {method} {path} should not be declared when has_config=false"
625            );
626        }
627    }
628
629    /// The default admin plugin guards its whole router with role-check
630    /// middleware, so its declared routes must classify `Gated` (carrying the
631    /// role) — not `Unclassified` — or `autumn routes audit` would false-fail
632    /// every protected admin route out of the box (#1604).
633    #[test]
634    fn admin_plugin_declared_routes_classify_gated_with_role() {
635        use autumn_web::route_listing::RouteClassification;
636
637        let routes = super::admin_route_infos("/admin", true, Some("admin"), false);
638        assert!(!routes.is_empty());
639        for r in &routes {
640            assert_eq!(
641                r.classification,
642                RouteClassification::Gated,
643                "admin route {} {} should be Gated, got {:?}",
644                r.method,
645                r.path,
646                r.classification
647            );
648            assert_eq!(
649                r.roles,
650                vec!["admin".to_owned()],
651                "admin route {} {} should carry the required role",
652                r.method,
653                r.path
654            );
655        }
656    }
657
658    /// The impersonation routes are declared only when
659    /// [`AdminPlugin::with_impersonation`] was called, and the revert route is
660    /// declared `Public` because it is deliberately mounted outside the role
661    /// gate (see `routes::admin_router`) — declaring it `Gated` would make the
662    /// route audit assert a guard that is not there.
663    #[test]
664    fn impersonation_routes_are_declared_only_when_enabled() {
665        use autumn_web::route_listing::RouteClassification;
666
667        let off = super::admin_route_infos("/admin", false, Some("admin"), false);
668        assert!(
669            !off.iter().any(|r| r.path.contains("/impersonate")),
670            "no impersonation routes without the opt-in"
671        );
672
673        let on = super::admin_route_infos("/admin", false, Some("admin"), true);
674        let begin = on
675            .iter()
676            .find(|r| r.path == "/admin/impersonate")
677            .expect("begin route declared");
678        assert_eq!(begin.method, "POST");
679        assert_eq!(begin.classification, RouteClassification::Gated);
680        assert_eq!(begin.roles, vec!["admin".to_owned()]);
681
682        let stop = on
683            .iter()
684            .find(|r| r.path == "/admin/impersonate/stop")
685            .expect("revert route declared");
686        assert_eq!(stop.method, "POST");
687        assert_eq!(
688            stop.classification,
689            RouteClassification::Public,
690            "the revert route is ungated on purpose"
691        );
692        assert!(stop.roles.is_empty());
693    }
694
695    /// When the plugin's auth is explicitly disabled (`require_role(None)`) the
696    /// declared routes classify `Public` — still a proven posture, so the audit
697    /// gate passes rather than flagging them `Unclassified`.
698    #[test]
699    fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
700        use autumn_web::route_listing::RouteClassification;
701
702        let routes = super::admin_route_infos("/admin", true, None, false);
703        assert!(!routes.is_empty());
704        for r in &routes {
705            assert_eq!(
706                r.classification,
707                RouteClassification::Public,
708                "admin route {} {} should be Public when auth disabled",
709                r.method,
710                r.path
711            );
712            assert!(r.roles.is_empty(), "public route should carry no roles");
713        }
714    }
715
716    #[test]
717    fn admin_plugin_has_no_route_collisions_in_isolation() {
718        let routes = admin_routes("/admin");
719        let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
720        assert_eq!(
721            result.status,
722            autumn_web::plugin_conformance::CheckStatus::Pass,
723            "unexpected collision: {}\n{:?}",
724            result.message,
725            result.diagnostics
726        );
727    }
728
729    #[test]
730    fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
731        let routes = admin_routes("/admin");
732        let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
733            path_pattern: "/admin".to_owned(),
734            auth_mechanism: "Role: admin required via AdminPlugin::require_role \
735                             (default) or AdminPlugin::require_role(None) to disable"
736                .to_owned(),
737        }];
738        let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
739            PLUGIN_NAME,
740            &routes,
741            &declared,
742        );
743        assert_eq!(
744            result.status,
745            autumn_web::plugin_conformance::CheckStatus::Pass,
746            "sensitive-surfaces check failed: {}",
747            result.message
748        );
749    }
750
751    #[test]
752    fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
753        let routes = admin_routes("/admin");
754        let result =
755            autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
756        assert_eq!(
757            result.status,
758            autumn_web::plugin_conformance::CheckStatus::Fail,
759            "expected FAIL when sensitive routes are undeclared"
760        );
761    }
762
763    #[test]
764    fn admin_plugin_passes_full_conformance_with_config() {
765        let routes = admin_routes("/admin");
766        let config = ConformanceConfig::new(PLUGIN_NAME)
767            .prefix("/admin")
768            .sensitive_route(
769                "/admin",
770                "Role: admin required via AdminPlugin::require_role",
771            );
772        let report = run_conformance(&config, &routes);
773        assert!(
774            report.passed(),
775            "AdminPlugin conformance failed:\n{}",
776            report.to_text_report()
777        );
778    }
779
780    #[test]
781    fn admin_plugin_collision_with_host_route_detected() {
782        let mut routes = admin_routes("/admin");
783        // Simulate a host app that accidentally defines GET /admin
784        routes.push(RouteInfo {
785            method: "GET".to_owned(),
786            path: "/admin".to_owned(),
787            handler: "host::admin_redirect".to_owned(),
788            source: RouteSource::User,
789            ..Default::default()
790        });
791        let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
792        assert_eq!(
793            result.status,
794            autumn_web::plugin_conformance::CheckStatus::Fail,
795            "expected collision to be detected"
796        );
797        let diag = &diagnostics[0];
798        assert_eq!(diag.method, "GET");
799        assert_eq!(diag.path, "/admin");
800        let sources: Vec<&str> = diag
801            .contributors
802            .iter()
803            .map(|c| c.source.as_str())
804            .collect();
805        assert!(
806            sources.contains(&"user"),
807            "missing user contributor: {sources:?}"
808        );
809        assert!(
810            sources.contains(&"plugin:autumn-admin-plugin"),
811            "missing plugin contributor: {sources:?}"
812        );
813    }
814
815    #[test]
816    fn admin_plugin_custom_prefix_passes_conformance() {
817        let routes = admin_routes("/backend");
818        let config = ConformanceConfig::new(PLUGIN_NAME)
819            .prefix("/backend")
820            .sensitive_route(
821                "/backend",
822                "Role: admin required via AdminPlugin::require_role",
823            );
824        let report = run_conformance(&config, &routes);
825        assert!(
826            report.passed(),
827            "AdminPlugin with custom prefix failed conformance:\n{}",
828            report.to_text_report()
829        );
830    }
831
832    #[test]
833    fn admin_plugin_double_registration_detected() {
834        // Simulate registering the admin plugin twice — its routes appear twice.
835        let mut routes = admin_routes("/admin");
836        routes.extend(admin_routes("/admin"));
837        let result =
838            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
839        assert_eq!(
840            result.status,
841            autumn_web::plugin_conformance::CheckStatus::Fail,
842            "expected duplicate-registration FAIL when plugin installed twice"
843        );
844    }
845
846    #[test]
847    fn admin_plugin_single_registration_passes_duplicate_check() {
848        let routes = admin_routes("/admin");
849        let result =
850            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
851        assert_eq!(
852            result.status,
853            autumn_web::plugin_conformance::CheckStatus::Pass,
854            "single registration should pass: {}",
855            result.message
856        );
857    }
858}