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 registry;
39mod routes;
40mod templates;
41pub mod tokens;
42mod traits;
43
44pub use registry::AdminRegistry;
45pub use traits::{
46    AdminAction, AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry,
47    AdminHistoryPage, AdminImportError, AdminImportReport, AdminImportRowResult, AdminModel,
48    CsvImportMode, ListParams, ListResult, SelectOption, SortDirection,
49};
50
51/// Common downstream imports for implementing admin models.
52pub mod prelude {
53    pub use crate::{
54        AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryEntry, AdminHistoryPage,
55        AdminImportRowResult, AdminModel, CsvImportMode, ListParams, ListResult, SelectOption,
56        SortDirection,
57    };
58}
59
60use std::borrow::Cow;
61use std::sync::Arc;
62
63use autumn_web::app::AppBuilder;
64use autumn_web::plugin::Plugin;
65use autumn_web::route_listing::RouteInfo;
66use autumn_web::runtime_config::RuntimeConfigService;
67
68/// The admin panel plugin.
69///
70/// Register models via `.register()` and the plugin will mount a full admin
71/// UI under the configured prefix (default: `/admin`).
72pub struct AdminPlugin {
73    registry: AdminRegistry,
74    prefix: String,
75    actuator_prefix: String,
76    auth_session_key: String,
77    require_role: Option<String>,
78    runtime_config: Option<Arc<RuntimeConfigService>>,
79    /// When `true`, every mutating action (create, update, destroy) on any
80    /// registered model requires step-up authentication before proceeding.
81    ///
82    /// Enables this with [`AdminPlugin::with_step_up_mutations`].
83    step_up_mutations: bool,
84    /// Freshness window for step-up checks on admin mutations (seconds).
85    /// Defaults to [`autumn_web::step_up::DEFAULT_MAX_AGE_SECS`].
86    /// Override with [`AdminPlugin::with_step_up_max_age`].
87    step_up_max_age_secs: u64,
88}
89
90impl AdminPlugin {
91    /// Create a new admin plugin with default settings.
92    ///
93    /// Mounts at `/admin` and requires the `"admin"` role in the session.
94    /// Links to the actuator UI under `/actuator`. Reads the user
95    /// identifier from session key `"user_id"` (Autumn's default
96    /// `auth.session_key`).
97    #[must_use]
98    pub fn new() -> Self {
99        Self {
100            registry: AdminRegistry::new(),
101            prefix: "/admin".to_owned(),
102            actuator_prefix: "/actuator".to_owned(),
103            auth_session_key: "user_id".to_owned(),
104            require_role: Some("admin".to_owned()),
105            runtime_config: None,
106            step_up_mutations: false,
107            step_up_max_age_secs: autumn_web::step_up::DEFAULT_MAX_AGE_SECS,
108        }
109    }
110
111    /// Override the URL prefix (default: `/admin`).
112    #[must_use]
113    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
114        self.prefix = prefix.into();
115        self
116    }
117
118    /// Override the actuator mount prefix that dashboard links/polling target
119    /// (default: `/actuator`). Must match `config.actuator.prefix` from your
120    /// autumn config โ€” the plugin cannot read it automatically because config
121    /// is loaded after `Plugin::build` runs.
122    #[must_use]
123    pub fn actuator_prefix(mut self, prefix: impl Into<String>) -> Self {
124        self.actuator_prefix = prefix.into();
125        self
126    }
127
128    /// Override the session key the role middleware reads to detect an
129    /// authenticated user. Default: `"user_id"`, matching Autumn's default
130    /// `auth.session_key`. Must match whatever your application populates
131    /// after login โ€” e.g. set this to `"uid"` if you configured
132    /// `auth.session_key = "uid"`.
133    ///
134    /// The plugin can't read `config.auth.session_key` automatically
135    /// because config is loaded after `Plugin::build` runs.
136    #[must_use]
137    pub fn auth_session_key(mut self, key: impl Into<String>) -> Self {
138        self.auth_session_key = key.into();
139        self
140    }
141
142    /// Set the required session role for accessing the admin panel.
143    ///
144    /// Pass `None` to disable role checks entirely. Authentication
145    /// (a populated `user_id` session key) is always required when a role
146    /// is set.
147    #[must_use]
148    pub fn require_role(mut self, role: impl Into<Option<String>>) -> Self {
149        self.require_role = role.into();
150        self
151    }
152
153    /// Register a model for admin management.
154    ///
155    /// The model must implement [`AdminModel`], which provides field metadata,
156    /// CRUD operations, and display configuration.
157    #[must_use]
158    pub fn register<M: AdminModel>(mut self, model: M) -> Self {
159        self.registry.register(model);
160        self
161    }
162
163    /// Enable the runtime config management page.
164    ///
165    /// Mounts `GET /config`, `POST /config/{key}/set`, `POST /config/{key}/unset`,
166    /// and `GET /config/{key}/history` under the admin prefix, and adds a
167    /// "Runtime Config" item to the sidebar navigation.
168    #[must_use]
169    pub fn with_runtime_config(mut self, svc: Arc<RuntimeConfigService>) -> Self {
170        self.runtime_config = Some(svc);
171        self
172    }
173
174    /// Require step-up (fresh) authentication before any mutating admin action.
175    ///
176    /// When enabled, every `POST` (create/update) and `DELETE` (destroy) request
177    /// to the admin panel is checked against the session's `last_strong_auth_at`
178    /// claim using the global step-up max-age configured in `[auth.step_up]`
179    /// (default: 5 minutes). Requests without a valid fresh-auth claim are
180    /// redirected to `/reauth?return_to=โ€ฆ` (HTML clients) or receive a
181    /// `401 step_up_required` problem-details response (JSON clients).
182    ///
183    /// Highly recommended for production admin panels to reduce the blast radius
184    /// of a hijacked admin session.
185    ///
186    /// # Example
187    ///
188    /// ```rust,ignore
189    /// AdminPlugin::new()
190    ///     .register(UserAdmin::default())
191    ///     .with_step_up_mutations()
192    /// ```
193    #[must_use]
194    pub const fn with_step_up_mutations(mut self) -> Self {
195        self.step_up_mutations = true;
196        self
197    }
198
199    /// Override the step-up freshness window for admin mutations.
200    ///
201    /// Only meaningful when [`with_step_up_mutations`](Self::with_step_up_mutations)
202    /// is also called. Calls `with_step_up_mutations` implicitly.
203    ///
204    /// # Example
205    ///
206    /// ```rust,ignore
207    /// AdminPlugin::new()
208    ///     .register(UserAdmin::default())
209    ///     .with_step_up_max_age(600) // 10-minute window
210    /// ```
211    #[must_use]
212    pub const fn with_step_up_max_age(mut self, secs: u64) -> Self {
213        self.step_up_mutations = true;
214        self.step_up_max_age_secs = secs;
215        self
216    }
217}
218
219impl Default for AdminPlugin {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225impl Plugin for AdminPlugin {
226    fn name(&self) -> Cow<'static, str> {
227        Cow::Borrowed("autumn-admin-plugin")
228    }
229
230    fn build(self, app: AppBuilder) -> AppBuilder {
231        let Self {
232            registry,
233            prefix,
234            actuator_prefix,
235            auth_session_key,
236            require_role,
237            runtime_config,
238            step_up_mutations,
239            step_up_max_age_secs,
240        } = self;
241        let has_config = runtime_config.is_some();
242        // "config" slug only conflicts when the runtime-config routes are mounted.
243        assert!(
244            !(has_config && registry.get("config").is_some()),
245            "autumn-admin: model slug 'config' conflicts with the mounted runtime-config \
246             routes; rename the model or don't call with_runtime_config",
247        );
248        let registry = Arc::new(registry);
249        let router = routes::admin_router(
250            Arc::clone(&registry),
251            &prefix,
252            actuator_prefix.clone(),
253            auth_session_key.clone(),
254            require_role.clone(),
255            runtime_config,
256            step_up_mutations,
257            step_up_max_age_secs,
258        );
259
260        tracing::info!(
261            prefix = %prefix,
262            actuator_prefix = %actuator_prefix,
263            auth_session_key = %auth_session_key,
264            models = registry.model_count(),
265            role = require_role.as_deref().unwrap_or("<none>"),
266            step_up_mutations = %step_up_mutations,
267            "๐Ÿ‚ Autumn Admin mounted"
268        );
269
270        // Declare routes for `autumn routes` listing. The underlying Axum router
271        // is added via nest() which is opaque to route enumeration, so we
272        // explicitly register route metadata here. Because the declared routes'
273        // paths all fall under the nest prefix, `autumn routes audit` treats this
274        // mount as covered (enumerable) instead of an omitted, unprovable raw
275        // router that would false-fail the gate.
276        let declared = admin_route_infos(&prefix, has_config, require_role.as_deref());
277
278        app.nest(&prefix, router).declare_plugin_routes(declared)
279    }
280}
281
282/// Generate the route metadata list for this plugin's mounted routes.
283///
284/// `has_config` must match whether `with_runtime_config` was called; config
285/// routes are only mounted when a `RuntimeConfigService` is provided, so
286/// including them unconditionally would produce false route-collision signals.
287///
288/// Kept in sync with `routes::admin_router` โ€” update here when routes are
289/// added or removed from the admin router.
290///
291/// `require_role` mirrors [`AdminPlugin::require_role`]: the whole admin router
292/// is wrapped in role-check middleware, so every declared route inherits that
293/// posture. When a role is required the routes classify [`Gated`] (carrying the
294/// role) rather than `Unclassified`; when the plugin's auth is explicitly
295/// disabled (`None`) they classify [`Public`]. Without this, the default admin
296/// plugin would false-fail `autumn routes audit` out of the box, since declared
297/// routes otherwise inherit `Unclassified` from `RouteInfo::default()`.
298///
299/// [`Gated`]: autumn_web::route_listing::RouteClassification::Gated
300/// [`Public`]: autumn_web::route_listing::RouteClassification::Public
301pub(crate) fn admin_route_infos(
302    prefix: &str,
303    has_config: bool,
304    require_role: Option<&str>,
305) -> Vec<RouteInfo> {
306    use autumn_web::route_listing::RouteClassification;
307
308    let (classification, roles) = require_role.map_or_else(
309        || (RouteClassification::Public, Vec::new()),
310        |role| (RouteClassification::Gated, vec![role.to_owned()]),
311    );
312    let mut entries: Vec<(&str, String)> = vec![
313        ("GET", prefix.to_string()),
314        ("GET", format!("{prefix}/jobs")),
315        ("GET", format!("{prefix}/jobs/counters")),
316        ("POST", format!("{prefix}/jobs/{{id}}/retry")),
317        ("POST", format!("{prefix}/jobs/{{id}}/discard")),
318        ("POST", format!("{prefix}/jobs/{{id}}/cancel")),
319    ];
320    if has_config {
321        entries.extend([
322            ("GET", format!("{prefix}/config")),
323            ("POST", format!("{prefix}/config/{{key}}/set")),
324            ("POST", format!("{prefix}/config/{{key}}/unset")),
325            ("GET", format!("{prefix}/config/{{key}}/history")),
326        ]);
327    }
328    entries.extend([
329        ("GET", format!("{prefix}/{{slug}}")),
330        ("POST", format!("{prefix}/{{slug}}")),
331        ("GET", format!("{prefix}/{{slug}}/new")),
332        ("GET", format!("{prefix}/{{slug}}/export.csv")),
333        ("GET", format!("{prefix}/{{slug}}/import")),
334        ("POST", format!("{prefix}/{{slug}}/import")),
335        ("GET", format!("{prefix}/{{slug}}/{{id}}")),
336        ("POST", format!("{prefix}/{{slug}}/{{id}}")),
337        ("DELETE", format!("{prefix}/{{slug}}/{{id}}")),
338        ("GET", format!("{prefix}/{{slug}}/{{id}}/edit")),
339        ("GET", format!("{prefix}/{{slug}}/{{id}}/history")),
340        ("POST", format!("{prefix}/{{slug}}/actions")),
341        ("GET", format!("{prefix}{}", *routes::ADMIN_JS_PATH)),
342    ]);
343    entries
344        .into_iter()
345        .map(|(method, path)| RouteInfo {
346            method: method.to_owned(),
347            path,
348            handler: format!("admin::{}", method.to_lowercase()),
349            source: autumn_web::route_listing::RouteSource::User, // overwritten by declare_plugin_routes
350            classification,
351            roles: roles.clone(),
352            ..Default::default()
353        })
354        .collect()
355}
356
357// โ”€โ”€ Conformance reference tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
358//
359// These tests are the reference example for the Autumn plugin conformance
360// workflow documented in docs/plugins.md.  They use
361// `autumn_web::plugin_conformance` to verify that the admin plugin's declared
362// routes satisfy all conformance checks before publication.
363//
364// See docs/plugins.md ยง "Plugin conformance and publishing checklist" for the
365// equivalent `autumn plugin-check` CLI invocation.
366
367#[cfg(test)]
368mod conformance_tests {
369    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
370    use autumn_web::route_listing::{RouteInfo, RouteSource};
371
372    const PLUGIN_NAME: &str = "autumn-admin-plugin";
373
374    /// Build the routes that `AdminPlugin` contributes under `prefix`,
375    /// attributed to the plugin. Reuses `admin_route_infos` from the outer
376    /// module and overrides the source to `Plugin(PLUGIN_NAME)`.
377    fn admin_routes(prefix: &str) -> Vec<RouteInfo> {
378        admin_routes_with_config(prefix, true)
379    }
380
381    fn admin_routes_with_config(prefix: &str, has_config: bool) -> Vec<RouteInfo> {
382        super::admin_route_infos(prefix, has_config, Some("admin"))
383            .into_iter()
384            .map(|mut r| {
385                r.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
386                r
387            })
388            .collect()
389    }
390
391    #[test]
392    fn admin_plugin_routes_are_attributed_to_plugin_name() {
393        let routes = admin_routes("/admin");
394        let result = autumn_web::plugin_conformance::check_route_attribution(PLUGIN_NAME, &routes);
395        assert_eq!(
396            result.status,
397            autumn_web::plugin_conformance::CheckStatus::Pass,
398            "route attribution failed: {}",
399            result.message
400        );
401    }
402
403    #[test]
404    fn admin_plugin_routes_live_under_admin_prefix() {
405        let routes = admin_routes("/admin");
406        let result =
407            autumn_web::plugin_conformance::check_route_prefix(PLUGIN_NAME, "/admin", &[], &routes);
408        assert_eq!(
409            result.status,
410            autumn_web::plugin_conformance::CheckStatus::Pass,
411            "prefix check failed: {}\n{:?}",
412            result.message,
413            result.diagnostics
414        );
415    }
416
417    #[test]
418    fn admin_plugin_declares_builtin_job_routes() {
419        let routes = admin_routes("/admin");
420        let declared: std::collections::HashSet<(&str, &str)> = routes
421            .iter()
422            .map(|route| (route.method.as_str(), route.path.as_str()))
423            .collect();
424
425        for (method, path) in [
426            ("GET", "/admin/jobs"),
427            ("GET", "/admin/jobs/counters"),
428            ("POST", "/admin/jobs/{id}/retry"),
429            ("POST", "/admin/jobs/{id}/discard"),
430            ("POST", "/admin/jobs/{id}/cancel"),
431        ] {
432            assert!(
433                declared.contains(&(method, path)),
434                "missing declared admin job route {method} {path}"
435            );
436        }
437    }
438
439    #[test]
440    fn admin_plugin_declares_builtin_config_routes_when_enabled() {
441        let routes = admin_routes_with_config("/admin", true);
442        let declared: std::collections::HashSet<(&str, &str)> = routes
443            .iter()
444            .map(|route| (route.method.as_str(), route.path.as_str()))
445            .collect();
446
447        for (method, path) in [
448            ("GET", "/admin/config"),
449            ("POST", "/admin/config/{key}/set"),
450            ("POST", "/admin/config/{key}/unset"),
451            ("GET", "/admin/config/{key}/history"),
452        ] {
453            assert!(
454                declared.contains(&(method, path)),
455                "missing declared admin config route {method} {path}"
456            );
457        }
458    }
459
460    #[test]
461    fn admin_plugin_omits_config_routes_when_disabled() {
462        let routes = admin_routes_with_config("/admin", false);
463        let declared: std::collections::HashSet<(&str, &str)> = routes
464            .iter()
465            .map(|route| (route.method.as_str(), route.path.as_str()))
466            .collect();
467
468        for (method, path) in [
469            ("GET", "/admin/config"),
470            ("POST", "/admin/config/{key}/set"),
471            ("POST", "/admin/config/{key}/unset"),
472            ("GET", "/admin/config/{key}/history"),
473        ] {
474            assert!(
475                !declared.contains(&(method, path)),
476                "config route {method} {path} should not be declared when has_config=false"
477            );
478        }
479    }
480
481    /// The default admin plugin guards its whole router with role-check
482    /// middleware, so its declared routes must classify `Gated` (carrying the
483    /// role) โ€” not `Unclassified` โ€” or `autumn routes audit` would false-fail
484    /// every protected admin route out of the box (#1604).
485    #[test]
486    fn admin_plugin_declared_routes_classify_gated_with_role() {
487        use autumn_web::route_listing::RouteClassification;
488
489        let routes = super::admin_route_infos("/admin", true, Some("admin"));
490        assert!(!routes.is_empty());
491        for r in &routes {
492            assert_eq!(
493                r.classification,
494                RouteClassification::Gated,
495                "admin route {} {} should be Gated, got {:?}",
496                r.method,
497                r.path,
498                r.classification
499            );
500            assert_eq!(
501                r.roles,
502                vec!["admin".to_owned()],
503                "admin route {} {} should carry the required role",
504                r.method,
505                r.path
506            );
507        }
508    }
509
510    /// When the plugin's auth is explicitly disabled (`require_role(None)`) the
511    /// declared routes classify `Public` โ€” still a proven posture, so the audit
512    /// gate passes rather than flagging them `Unclassified`.
513    #[test]
514    fn admin_plugin_declared_routes_classify_public_when_role_disabled() {
515        use autumn_web::route_listing::RouteClassification;
516
517        let routes = super::admin_route_infos("/admin", true, None);
518        assert!(!routes.is_empty());
519        for r in &routes {
520            assert_eq!(
521                r.classification,
522                RouteClassification::Public,
523                "admin route {} {} should be Public when auth disabled",
524                r.method,
525                r.path
526            );
527            assert!(r.roles.is_empty(), "public route should carry no roles");
528        }
529    }
530
531    #[test]
532    fn admin_plugin_has_no_route_collisions_in_isolation() {
533        let routes = admin_routes("/admin");
534        let (result, _) = autumn_web::plugin_conformance::check_collisions(&routes);
535        assert_eq!(
536            result.status,
537            autumn_web::plugin_conformance::CheckStatus::Pass,
538            "unexpected collision: {}\n{:?}",
539            result.message,
540            result.diagnostics
541        );
542    }
543
544    #[test]
545    fn admin_plugin_sensitive_surfaces_declared_with_role_requirement() {
546        let routes = admin_routes("/admin");
547        let declared = vec![autumn_web::plugin_conformance::SensitiveRoute {
548            path_pattern: "/admin".to_owned(),
549            auth_mechanism: "Role: admin required via AdminPlugin::require_role \
550                             (default) or AdminPlugin::require_role(None) to disable"
551                .to_owned(),
552        }];
553        let result = autumn_web::plugin_conformance::check_sensitive_surfaces(
554            PLUGIN_NAME,
555            &routes,
556            &declared,
557        );
558        assert_eq!(
559            result.status,
560            autumn_web::plugin_conformance::CheckStatus::Pass,
561            "sensitive-surfaces check failed: {}",
562            result.message
563        );
564    }
565
566    #[test]
567    fn admin_plugin_sensitive_surfaces_fail_without_declaration() {
568        let routes = admin_routes("/admin");
569        let result =
570            autumn_web::plugin_conformance::check_sensitive_surfaces(PLUGIN_NAME, &routes, &[]);
571        assert_eq!(
572            result.status,
573            autumn_web::plugin_conformance::CheckStatus::Fail,
574            "expected FAIL when sensitive routes are undeclared"
575        );
576    }
577
578    #[test]
579    fn admin_plugin_passes_full_conformance_with_config() {
580        let routes = admin_routes("/admin");
581        let config = ConformanceConfig::new(PLUGIN_NAME)
582            .prefix("/admin")
583            .sensitive_route(
584                "/admin",
585                "Role: admin required via AdminPlugin::require_role",
586            );
587        let report = run_conformance(&config, &routes);
588        assert!(
589            report.passed(),
590            "AdminPlugin conformance failed:\n{}",
591            report.to_text_report()
592        );
593    }
594
595    #[test]
596    fn admin_plugin_collision_with_host_route_detected() {
597        let mut routes = admin_routes("/admin");
598        // Simulate a host app that accidentally defines GET /admin
599        routes.push(RouteInfo {
600            method: "GET".to_owned(),
601            path: "/admin".to_owned(),
602            handler: "host::admin_redirect".to_owned(),
603            source: RouteSource::User,
604            ..Default::default()
605        });
606        let (result, diagnostics) = autumn_web::plugin_conformance::check_collisions(&routes);
607        assert_eq!(
608            result.status,
609            autumn_web::plugin_conformance::CheckStatus::Fail,
610            "expected collision to be detected"
611        );
612        let diag = &diagnostics[0];
613        assert_eq!(diag.method, "GET");
614        assert_eq!(diag.path, "/admin");
615        let sources: Vec<&str> = diag
616            .contributors
617            .iter()
618            .map(|c| c.source.as_str())
619            .collect();
620        assert!(
621            sources.contains(&"user"),
622            "missing user contributor: {sources:?}"
623        );
624        assert!(
625            sources.contains(&"plugin:autumn-admin-plugin"),
626            "missing plugin contributor: {sources:?}"
627        );
628    }
629
630    #[test]
631    fn admin_plugin_custom_prefix_passes_conformance() {
632        let routes = admin_routes("/backend");
633        let config = ConformanceConfig::new(PLUGIN_NAME)
634            .prefix("/backend")
635            .sensitive_route(
636                "/backend",
637                "Role: admin required via AdminPlugin::require_role",
638            );
639        let report = run_conformance(&config, &routes);
640        assert!(
641            report.passed(),
642            "AdminPlugin with custom prefix failed conformance:\n{}",
643            report.to_text_report()
644        );
645    }
646
647    #[test]
648    fn admin_plugin_double_registration_detected() {
649        // Simulate registering the admin plugin twice โ€” its routes appear twice.
650        let mut routes = admin_routes("/admin");
651        routes.extend(admin_routes("/admin"));
652        let result =
653            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
654        assert_eq!(
655            result.status,
656            autumn_web::plugin_conformance::CheckStatus::Fail,
657            "expected duplicate-registration FAIL when plugin installed twice"
658        );
659    }
660
661    #[test]
662    fn admin_plugin_single_registration_passes_duplicate_check() {
663        let routes = admin_routes("/admin");
664        let result =
665            autumn_web::plugin_conformance::check_duplicate_registration(PLUGIN_NAME, &routes);
666        assert_eq!(
667            result.status,
668            autumn_web::plugin_conformance::CheckStatus::Pass,
669            "single registration should pass: {}",
670            result.message
671        );
672    }
673}