Skip to main content

autumn_web/
plugin_conformance.rs

1//! Plugin conformance checks for Autumn plugin authors.
2//!
3//! Provides types and functions to verify that a plugin's route contributions
4//! are safe, correctly attributed, and ready to publish. Plugin authors use
5//! this module in integration tests to get a pass/fail conformance report
6//! before publishing their crate.
7//!
8//! # Quick start
9//!
10//! ```rust,no_run
11//! use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
12//!
13//! // Build a minimal host app that installs the plugin, then inspect its routes
14//! // via AppBuilder::plugin_route_infos() or via `autumn plugin-check` in CI.
15//! let config = ConformanceConfig::new("autumn-admin-plugin")
16//!     .prefix("/admin")
17//!     .sensitive_route("/admin", "Role: admin required via AdminPlugin::require_role");
18//! // Pass route_infos collected from AppBuilder to run_conformance(...)
19//! ```
20
21use serde::{Deserialize, Serialize};
22
23use crate::route_listing::{RouteInfo, RouteSource};
24
25// ── Configuration ──────────────────────────────────────────────────────────
26
27/// Configuration for a conformance run against a specific plugin.
28#[derive(Debug, Clone)]
29pub struct ConformanceConfig {
30    /// The documented plugin name (e.g. `"autumn-admin-plugin"`).
31    pub plugin_name: String,
32    /// Expected URL prefix for all plugin routes (e.g. `"/admin"`).
33    /// If `None`, the route-prefix check is skipped.
34    pub expected_prefix: Option<String>,
35    /// Paths that are intentionally at the root level (not under `expected_prefix`).
36    /// Each entry must be the exact path as it appears in the route manifest.
37    pub intentional_root_routes: Vec<String>,
38    /// Sensitive surface declarations: routes whose path contains keywords like
39    /// `admin`, `debug`, `credential`, `operator`, `secret`, or `metrics` must
40    /// appear here with a non-empty `auth_mechanism`, or the check fails.
41    pub sensitive_routes: Vec<SensitiveRoute>,
42}
43
44impl ConformanceConfig {
45    /// Create a minimal config with only the plugin name.
46    pub fn new(plugin_name: impl Into<String>) -> Self {
47        Self {
48            plugin_name: plugin_name.into(),
49            expected_prefix: None,
50            intentional_root_routes: Vec::new(),
51            sensitive_routes: Vec::new(),
52        }
53    }
54
55    /// Declare the expected route prefix (e.g. `"/admin"`).
56    #[must_use]
57    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
58        self.expected_prefix = Some(prefix.into());
59        self
60    }
61
62    /// Declare a path as an intentional root-level route, exempting it from
63    /// the prefix check (e.g. `"/webhook"`).
64    #[must_use]
65    pub fn intentional_root_route(mut self, path: impl Into<String>) -> Self {
66        self.intentional_root_routes.push(path.into());
67        self
68    }
69
70    /// Declare a sensitive route with its auth/profile gating mechanism.
71    ///
72    /// `path_pattern` is a prefix that matches the route path (e.g. `"/admin"`).
73    /// `auth_mechanism` is a human-readable description (e.g. `"Role: admin required"`).
74    /// An empty `auth_mechanism` still fails the check — the string must be non-empty.
75    #[must_use]
76    pub fn sensitive_route(
77        mut self,
78        path_pattern: impl Into<String>,
79        auth_mechanism: impl Into<String>,
80    ) -> Self {
81        self.sensitive_routes.push(SensitiveRoute {
82            path_pattern: path_pattern.into(),
83            auth_mechanism: auth_mechanism.into(),
84        });
85        self
86    }
87}
88
89/// Declaration of a sensitive route and its auth/profile gating mechanism.
90#[derive(Debug, Clone)]
91pub struct SensitiveRoute {
92    /// Path prefix that matches sensitive routes (e.g. `"/admin"`).
93    pub path_pattern: String,
94    /// Human-readable description of the gating mechanism
95    /// (e.g. `"Role: admin required via AdminPlugin::require_role"`).
96    pub auth_mechanism: String,
97}
98
99// ── Report types ───────────────────────────────────────────────────────────
100
101/// Status of an individual conformance check.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum CheckStatus {
105    /// The check passed.
106    Pass,
107    /// The check found a problem that must be resolved before publishing.
108    Fail,
109    /// The check was skipped (e.g. no routes attributed to the plugin).
110    Skip,
111}
112
113impl std::fmt::Display for CheckStatus {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::Pass => write!(f, "PASS"),
117            Self::Fail => write!(f, "FAIL"),
118            Self::Skip => write!(f, "SKIP"),
119        }
120    }
121}
122
123/// Result of a single conformance check.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CheckResult {
126    /// Short check identifier (e.g. `"route-attribution"`).
127    pub name: String,
128    /// Pass, fail, or skip.
129    pub status: CheckStatus,
130    /// Human-readable description of the result.
131    pub message: String,
132    /// Additional diagnostic lines (e.g. collision details, off-prefix paths).
133    pub diagnostics: Vec<String>,
134}
135
136/// Information about two or more routes that collide on the same (method, path).
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CollisionDiagnostic {
139    /// HTTP method of the collision.
140    pub method: String,
141    /// URL path of the collision.
142    pub path: String,
143    /// All routes that contribute to this collision.
144    pub contributors: Vec<RouteContributor>,
145}
146
147impl CollisionDiagnostic {
148    fn to_diagnostic_string(&self) -> String {
149        let contributors: Vec<String> = self
150            .contributors
151            .iter()
152            .map(|c| format!("{} (source: {})", c.handler, c.source))
153            .collect();
154        format!(
155            "{} {} — collides between: {}",
156            self.method,
157            self.path,
158            contributors.join(", ")
159        )
160    }
161}
162
163/// A single route that contributes to a collision.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct RouteContributor {
166    /// Registration source (e.g. `"user"`, `"plugin:admin"`, `"framework"`).
167    pub source: String,
168    /// Handler function name (e.g. `"posts::create"`).
169    pub handler: String,
170}
171
172/// The full conformance report for a plugin.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ConformanceReport {
175    /// The plugin name this report covers.
176    pub plugin_name: String,
177    /// Results for each conformance check that was run.
178    pub checks: Vec<CheckResult>,
179}
180
181impl ConformanceReport {
182    /// Returns `true` when no check has `CheckStatus::Fail`.
183    /// Skipped checks do not count as failures.
184    #[must_use]
185    pub fn passed(&self) -> bool {
186        self.checks.iter().all(|c| c.status != CheckStatus::Fail)
187    }
188
189    /// Render the report as a human-readable text string.
190    #[must_use]
191    pub fn to_text_report(&self) -> String {
192        let mut out = String::new();
193        let overall = if self.passed() { "PASS" } else { "FAIL" };
194        out.push_str("Plugin conformance: ");
195        out.push_str(&self.plugin_name);
196        out.push_str(" — ");
197        out.push_str(overall);
198        out.push('\n');
199        out.push_str(&"─".repeat(60));
200        out.push('\n');
201        for check in &self.checks {
202            let icon = match check.status {
203                CheckStatus::Pass => "✓",
204                CheckStatus::Fail => "✗",
205                CheckStatus::Skip => "−",
206            };
207            out.push_str(icon);
208            out.push_str(" [");
209            out.push_str(&check.status.to_string());
210            out.push_str("] ");
211            out.push_str(&check.name);
212            out.push_str(": ");
213            out.push_str(&check.message);
214            out.push('\n');
215            for diag in &check.diagnostics {
216                out.push_str("  → ");
217                out.push_str(diag);
218                out.push('\n');
219            }
220        }
221        out.push_str(&"─".repeat(60));
222        out.push('\n');
223        if self.passed() {
224            out.push_str("All conformance checks passed.\n");
225        } else {
226            let fails = self
227                .checks
228                .iter()
229                .filter(|c| c.status == CheckStatus::Fail)
230                .count();
231            out.push_str(&fails.to_string());
232            out.push_str(" check(s) failed.\n");
233        }
234        out
235    }
236}
237
238// ── Sensitive path classification ──────────────────────────────────────────
239
240const SENSITIVE_KEYWORDS: &[&str] = &[
241    "admin",
242    "debug",
243    "credential",
244    "operator",
245    "secret",
246    "metrics",
247];
248
249/// Returns `true` when `path` contains a segment that is or starts with a
250/// sensitive keyword (case-insensitive).
251fn is_sensitive_path(path: &str) -> bool {
252    let lower = path.to_lowercase();
253    SENSITIVE_KEYWORDS.iter().any(|kw| {
254        lower
255            .split('/')
256            .any(|segment| segment == *kw || segment.starts_with(kw))
257    })
258}
259
260// ── Individual check functions ─────────────────────────────────────────────
261
262/// Check that all routes attributed to the plugin carry the expected
263/// `Plugin("<plugin_name>")` source.
264///
265/// Returns `Skip` when no routes at all are attributed to the plugin.
266#[must_use]
267pub fn check_route_attribution(plugin_name: &str, routes: &[RouteInfo]) -> CheckResult {
268    let plugin_routes: Vec<&RouteInfo> = routes
269        .iter()
270        .filter(|r| matches!(&r.source, RouteSource::Plugin(n) if n == plugin_name))
271        .collect();
272
273    if plugin_routes.is_empty() {
274        return CheckResult {
275            name: "route-attribution".to_owned(),
276            status: CheckStatus::Fail,
277            message: format!(
278                "No routes attributed to plugin:{plugin_name} — \
279                 check the plugin name or call AppBuilder::declare_plugin_routes"
280            ),
281            diagnostics: vec![],
282        };
283    }
284
285    CheckResult {
286        name: "route-attribution".to_owned(),
287        status: CheckStatus::Pass,
288        message: format!(
289            "{} route(s) correctly attributed to plugin:{plugin_name}",
290            plugin_routes.len()
291        ),
292        diagnostics: vec![],
293    }
294}
295
296/// Check that all plugin routes live under `prefix`.
297///
298/// Routes listed in `intentional_root` (exact path match) are exempt.
299/// Returns `Skip` when no routes are attributed to the plugin.
300#[must_use]
301pub fn check_route_prefix(
302    plugin_name: &str,
303    prefix: &str,
304    intentional_root: &[String],
305    routes: &[RouteInfo],
306) -> CheckResult {
307    let plugin_routes: Vec<&RouteInfo> = routes
308        .iter()
309        .filter(|r| matches!(&r.source, RouteSource::Plugin(n) if n == plugin_name))
310        .collect();
311
312    if plugin_routes.is_empty() {
313        return CheckResult {
314            name: "route-prefix".to_owned(),
315            status: CheckStatus::Skip,
316            message: format!("No routes attributed to plugin:{plugin_name}"),
317            diagnostics: vec![],
318        };
319    }
320
321    let under_prefix = |path: &str| path == prefix || path.starts_with(&format!("{prefix}/"));
322    let off_prefix: Vec<String> = plugin_routes
323        .iter()
324        .filter(|r| !under_prefix(&r.path) && !intentional_root.contains(&r.path))
325        .map(|r| format!("{} {}", r.method, r.path))
326        .collect();
327
328    if off_prefix.is_empty() {
329        CheckResult {
330            name: "route-prefix".to_owned(),
331            status: CheckStatus::Pass,
332            message: format!("All plugin routes live under {prefix}"),
333            diagnostics: vec![],
334        }
335    } else {
336        CheckResult {
337            name: "route-prefix".to_owned(),
338            status: CheckStatus::Fail,
339            message: format!(
340                "{} route(s) not under prefix {prefix} and not declared as intentional root routes",
341                off_prefix.len()
342            ),
343            diagnostics: off_prefix,
344        }
345    }
346}
347
348/// Canonicalize dynamic route segments before collision detection.
349///
350/// Both named params (`{id}`) and catch-all params (`{*rest}`) normalize to
351/// `{}`. matchit (Axum's router) treats a named param and a catch-all at the
352/// same path position as a conflict, so they must map to the same key.
353fn normalize_path_for_collision(path: &str) -> String {
354    path.split('/')
355        .map(|seg| {
356            if seg.starts_with('{') && seg.ends_with('}') {
357                "{}"
358            } else {
359                seg
360            }
361        })
362        .collect::<Vec<_>>()
363        .join("/")
364}
365
366/// Detect route collisions: any two routes sharing the same (method, path) pair.
367///
368/// Dynamic segment names are normalized before comparison so that
369/// `GET /users/{user_id}` and `GET /users/{id}` are correctly detected as
370/// colliding. The `path` field in each `CollisionDiagnostic` reflects the
371/// normalized shape (e.g. `/users/{}`).
372///
373/// Returns both a `CheckResult` and the full list of `CollisionDiagnostic` values
374/// so callers can serialize detailed collision info in JSON output.
375pub fn check_collisions(routes: &[RouteInfo]) -> (CheckResult, Vec<CollisionDiagnostic>) {
376    use std::collections::HashMap;
377
378    let mut by_key: HashMap<(String, String), Vec<&RouteInfo>> = HashMap::new();
379    for route in routes {
380        by_key
381            .entry((
382                route.method.clone(),
383                normalize_path_for_collision(&route.path),
384            ))
385            .or_default()
386            .push(route);
387    }
388
389    let mut diagnostics: Vec<CollisionDiagnostic> = by_key
390        .into_iter()
391        .filter(|(_, rs)| rs.len() > 1)
392        .map(|((method, path), rs)| CollisionDiagnostic {
393            method,
394            path,
395            contributors: rs
396                .iter()
397                .map(|r| RouteContributor {
398                    source: r.source.to_string(),
399                    handler: r.handler.clone(),
400                })
401                .collect(),
402        })
403        .collect();
404
405    diagnostics.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
406
407    if diagnostics.is_empty() {
408        (
409            CheckResult {
410                name: "route-collision".to_owned(),
411                status: CheckStatus::Pass,
412                message: "No route collisions detected".to_owned(),
413                diagnostics: vec![],
414            },
415            diagnostics,
416        )
417    } else {
418        let diag_strings: Vec<String> = diagnostics
419            .iter()
420            .map(CollisionDiagnostic::to_diagnostic_string)
421            .collect();
422        (
423            CheckResult {
424                name: "route-collision".to_owned(),
425                status: CheckStatus::Fail,
426                message: format!("{} route collision(s) detected", diagnostics.len()),
427                diagnostics: diag_strings,
428            },
429            diagnostics,
430        )
431    }
432}
433
434/// Check that sensitive-sounding plugin routes are declared with an auth mechanism.
435///
436/// "Sensitive" paths contain segments matching `admin`, `debug`, `credential`,
437/// `operator`, `secret`, or `metrics`. Each must appear in
438/// `ConformanceConfig::sensitive_routes` with a non-empty `auth_mechanism`.
439///
440/// Returns `Pass` when no sensitive-named plugin routes are found.
441#[must_use]
442pub fn check_sensitive_surfaces(
443    plugin_name: &str,
444    routes: &[RouteInfo],
445    declared: &[SensitiveRoute],
446) -> CheckResult {
447    let sensitive_plugin_routes: Vec<&RouteInfo> = routes
448        .iter()
449        .filter(|r| {
450            matches!(&r.source, RouteSource::Plugin(n) if n == plugin_name)
451                && is_sensitive_path(&r.path)
452        })
453        .collect();
454
455    if sensitive_plugin_routes.is_empty() {
456        return CheckResult {
457            name: "sensitive-surfaces".to_owned(),
458            status: CheckStatus::Pass,
459            message: "No sensitive-named routes detected".to_owned(),
460            diagnostics: vec![],
461        };
462    }
463
464    let mut undeclared: Vec<String> = Vec::new();
465    for route in &sensitive_plugin_routes {
466        let is_declared = declared.iter().any(|d| {
467            route.path.starts_with(&d.path_pattern) && !d.auth_mechanism.trim().is_empty()
468        });
469        if !is_declared {
470            undeclared.push(format!(
471                "{} {} — undeclared sensitive surface",
472                route.method, route.path
473            ));
474        }
475    }
476
477    if undeclared.is_empty() {
478        CheckResult {
479            name: "sensitive-surfaces".to_owned(),
480            status: CheckStatus::Pass,
481            message: format!(
482                "{} sensitive route(s) declared with auth/profile gating mechanisms",
483                sensitive_plugin_routes.len()
484            ),
485            diagnostics: vec![],
486        }
487    } else {
488        let mut diagnostics = undeclared;
489        diagnostics.push(
490            "Add .sensitive_route(path_prefix, auth_mechanism) to ConformanceConfig".to_owned(),
491        );
492        CheckResult {
493            name: "sensitive-surfaces".to_owned(),
494            status: CheckStatus::Fail,
495            message: format!(
496                "{} sensitive-named route(s) not declared with auth/profile gating",
497                diagnostics.len() - 1
498            ),
499            diagnostics,
500        }
501    }
502}
503
504/// Check that the plugin's routes are not duplicated, which would indicate the
505/// plugin was registered more than once and the framework's dedup logic was
506/// bypassed.
507///
508/// Detects (method, path) pairs that appear more than once among routes
509/// attributed to the named plugin. Returns `Skip` when no routes are
510/// attributed to the plugin.
511#[must_use]
512pub fn check_duplicate_registration(plugin_name: &str, routes: &[RouteInfo]) -> CheckResult {
513    use std::collections::HashMap;
514
515    let plugin_routes: Vec<&RouteInfo> = routes
516        .iter()
517        .filter(|r| matches!(&r.source, RouteSource::Plugin(n) if n == plugin_name))
518        .collect();
519
520    if plugin_routes.is_empty() {
521        return CheckResult {
522            name: "duplicate-registration".to_owned(),
523            status: CheckStatus::Skip,
524            message: format!("No routes attributed to plugin:{plugin_name}"),
525            diagnostics: vec![],
526        };
527    }
528
529    let mut counts: HashMap<(String, String), usize> = HashMap::new();
530    for route in &plugin_routes {
531        *counts
532            .entry((route.method.clone(), route.path.clone()))
533            .or_insert(0) += 1;
534    }
535
536    let mut duplicates: Vec<String> = counts
537        .into_iter()
538        .filter(|(_, count)| *count > 1)
539        .map(|((method, path), count)| format!("{method} {path} — appears {count} times"))
540        .collect();
541    duplicates.sort();
542
543    if duplicates.is_empty() {
544        CheckResult {
545            name: "duplicate-registration".to_owned(),
546            status: CheckStatus::Pass,
547            message: format!("No duplicate route registrations for plugin:{plugin_name}"),
548            diagnostics: vec![],
549        }
550    } else {
551        CheckResult {
552            name: "duplicate-registration".to_owned(),
553            status: CheckStatus::Fail,
554            message: format!(
555                "{} route(s) registered more than once; plugin:{plugin_name} \
556                 may have been installed twice",
557                duplicates.len()
558            ),
559            diagnostics: duplicates,
560        }
561    }
562}
563
564// ── Main entry point ───────────────────────────────────────────────────────
565
566/// Run all conformance checks and return a `ConformanceReport`.
567///
568/// Checks run:
569/// 1. `route-attribution` — plugin routes carry `plugin:<name>` source
570/// 2. `route-prefix` — plugin routes live under `config.expected_prefix` (if set)
571/// 3. `route-collision` — no two routes share (method, path)
572/// 4. `sensitive-surfaces` — sensitive-named plugin routes are declared with auth
573/// 5. `duplicate-registration` — plugin routes are not registered more than once
574#[must_use]
575pub fn run_conformance(config: &ConformanceConfig, routes: &[RouteInfo]) -> ConformanceReport {
576    let mut checks = Vec::new();
577
578    checks.push(check_route_attribution(&config.plugin_name, routes));
579
580    if let Some(ref prefix) = config.expected_prefix {
581        checks.push(check_route_prefix(
582            &config.plugin_name,
583            prefix,
584            &config.intentional_root_routes,
585            routes,
586        ));
587    }
588
589    let (collision_check, _) = check_collisions(routes);
590    checks.push(collision_check);
591
592    checks.push(check_sensitive_surfaces(
593        &config.plugin_name,
594        routes,
595        &config.sensitive_routes,
596    ));
597
598    checks.push(check_duplicate_registration(&config.plugin_name, routes));
599
600    ConformanceReport {
601        plugin_name: config.plugin_name.clone(),
602        checks,
603    }
604}
605
606// ── Tests ──────────────────────────────────────────────────────────────────
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn make_route(method: &str, path: &str, source: RouteSource) -> RouteInfo {
613        RouteInfo {
614            method: method.to_owned(),
615            path: path.to_owned(),
616            handler: format!("{}_handler", path.trim_start_matches('/').replace('/', "_")),
617            source,
618            middleware: vec![],
619        }
620    }
621
622    fn plugin(name: &str) -> RouteSource {
623        RouteSource::Plugin(name.to_owned())
624    }
625
626    // ── check_route_attribution ────────────────────────────────────────────
627
628    #[test]
629    fn attribution_all_attributed_passes() {
630        let routes = vec![
631            make_route("GET", "/admin", plugin("admin")),
632            make_route("POST", "/admin/items", plugin("admin")),
633        ];
634        let result = check_route_attribution("admin", &routes);
635        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
636    }
637
638    #[test]
639    fn attribution_no_plugin_routes_fails() {
640        let routes = vec![make_route("GET", "/posts", RouteSource::User)];
641        let result = check_route_attribution("admin", &routes);
642        assert_eq!(result.status, CheckStatus::Fail);
643        assert!(
644            result.message.contains("plugin:admin"),
645            "message should name the plugin: {}",
646            result.message
647        );
648    }
649
650    #[test]
651    fn attribution_pass_message_includes_count() {
652        let routes = vec![
653            make_route("GET", "/admin", plugin("admin")),
654            make_route("GET", "/admin/items", plugin("admin")),
655        ];
656        let result = check_route_attribution("admin", &routes);
657        assert!(
658            result.message.contains('2'),
659            "expected count in message: {}",
660            result.message
661        );
662    }
663
664    #[test]
665    fn attribution_other_plugin_routes_not_counted() {
666        let routes = vec![
667            make_route("GET", "/harvest/feeds", plugin("harvest")),
668            make_route("GET", "/admin", plugin("admin")),
669        ];
670        let result = check_route_attribution("admin", &routes);
671        assert_eq!(result.status, CheckStatus::Pass);
672        assert!(
673            result.message.contains('1'),
674            "only 1 admin route: {}",
675            result.message
676        );
677    }
678
679    // ── check_route_prefix ─────────────────────────────────────────────────
680
681    #[test]
682    fn prefix_all_under_prefix_passes() {
683        let routes = vec![
684            make_route("GET", "/admin", plugin("admin")),
685            make_route("POST", "/admin/items", plugin("admin")),
686        ];
687        let result = check_route_prefix("admin", "/admin", &[], &routes);
688        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
689    }
690
691    #[test]
692    fn prefix_route_outside_prefix_fails() {
693        let routes = vec![
694            make_route("GET", "/admin", plugin("admin")),
695            make_route("GET", "/webhook", plugin("admin")),
696        ];
697        let result = check_route_prefix("admin", "/admin", &[], &routes);
698        assert_eq!(result.status, CheckStatus::Fail, "{}", result.message);
699    }
700
701    #[test]
702    fn prefix_off_prefix_diagnostic_names_the_route() {
703        let routes = vec![make_route("GET", "/webhook", plugin("admin"))];
704        let result = check_route_prefix("admin", "/admin", &[], &routes);
705        assert_eq!(result.status, CheckStatus::Fail);
706        assert!(
707            result.diagnostics.iter().any(|d| d.contains("/webhook")),
708            "expected /webhook in diagnostics: {:?}",
709            result.diagnostics
710        );
711    }
712
713    #[test]
714    fn prefix_intentional_root_exempted() {
715        let routes = vec![
716            make_route("GET", "/admin", plugin("admin")),
717            make_route("GET", "/webhook", plugin("admin")),
718        ];
719        let intentional = vec!["/webhook".to_owned()];
720        let result = check_route_prefix("admin", "/admin", &intentional, &routes);
721        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
722    }
723
724    #[test]
725    fn prefix_sibling_path_with_same_string_prefix_fails() {
726        let routes = vec![make_route("GET", "/administer/settings", plugin("admin"))];
727        let result = check_route_prefix("admin", "/admin", &[], &routes);
728        assert_eq!(
729            result.status,
730            CheckStatus::Fail,
731            "/administer/settings should not pass /admin prefix check"
732        );
733    }
734
735    #[test]
736    fn prefix_exact_match_passes() {
737        let routes = vec![make_route("GET", "/admin", plugin("admin"))];
738        let result = check_route_prefix("admin", "/admin", &[], &routes);
739        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
740    }
741
742    #[test]
743    fn prefix_no_plugin_routes_skips() {
744        let routes = vec![make_route("GET", "/posts", RouteSource::User)];
745        let result = check_route_prefix("admin", "/admin", &[], &routes);
746        assert_eq!(result.status, CheckStatus::Skip);
747    }
748
749    // ── check_collisions ───────────────────────────────────────────────────
750
751    #[test]
752    fn collisions_no_collisions_passes() {
753        let routes = vec![
754            make_route("GET", "/posts", RouteSource::User),
755            make_route("GET", "/admin", plugin("admin")),
756            make_route("POST", "/posts", RouteSource::User),
757        ];
758        let (result, diagnostics) = check_collisions(&routes);
759        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
760        assert!(diagnostics.is_empty());
761    }
762
763    #[test]
764    fn collisions_host_plugin_collision_fails() {
765        let routes = vec![
766            make_route("GET", "/posts", RouteSource::User),
767            make_route("GET", "/posts", plugin("harvest")),
768        ];
769        let (result, diagnostics) = check_collisions(&routes);
770        assert_eq!(result.status, CheckStatus::Fail, "{}", result.message);
771        assert_eq!(diagnostics.len(), 1);
772    }
773
774    #[test]
775    fn collisions_plugin_plugin_collision_fails() {
776        let routes = vec![
777            make_route("GET", "/api/feed", plugin("harvest")),
778            make_route("GET", "/api/feed", plugin("feeds")),
779        ];
780        let (result, diagnostics) = check_collisions(&routes);
781        assert_eq!(result.status, CheckStatus::Fail);
782        assert_eq!(diagnostics.len(), 1);
783    }
784
785    #[test]
786    fn collisions_diagnostic_has_method_path_contributors() {
787        let routes = vec![
788            make_route("POST", "/items", RouteSource::User),
789            make_route("POST", "/items", plugin("inventory")),
790        ];
791        let (_, diagnostics) = check_collisions(&routes);
792        assert_eq!(diagnostics.len(), 1);
793        let diag = &diagnostics[0];
794        assert_eq!(diag.method, "POST");
795        assert_eq!(diag.path, "/items");
796        assert_eq!(diag.contributors.len(), 2);
797        let sources: Vec<&str> = diag
798            .contributors
799            .iter()
800            .map(|c| c.source.as_str())
801            .collect();
802        assert!(sources.contains(&"user"), "missing user: {sources:?}");
803        assert!(
804            sources.contains(&"plugin:inventory"),
805            "missing plugin:inventory: {sources:?}"
806        );
807    }
808
809    #[test]
810    fn collisions_diagnostic_string_names_method_and_path() {
811        let routes = vec![
812            make_route("DELETE", "/items/{id}", RouteSource::User),
813            make_route("DELETE", "/items/{id}", plugin("inventory")),
814        ];
815        let (result, _) = check_collisions(&routes);
816        // Path is shown in normalized form ({id} → {}) so the reader
817        // sees the structural shape that Axum matches on.
818        assert!(
819            result
820                .diagnostics
821                .iter()
822                .any(|d| d.contains("DELETE") && d.contains("/items/{}")),
823            "diagnostic should mention method and normalized path: {:?}",
824            result.diagnostics
825        );
826    }
827
828    #[test]
829    fn collisions_different_methods_same_path_no_collision() {
830        let routes = vec![
831            make_route("GET", "/posts", RouteSource::User),
832            make_route("POST", "/posts", plugin("blog")),
833        ];
834        let (result, _) = check_collisions(&routes);
835        assert_eq!(result.status, CheckStatus::Pass);
836    }
837
838    #[test]
839    fn collisions_dynamic_segment_different_names_detected() {
840        let routes = vec![
841            make_route("GET", "/users/{user_id}", RouteSource::User),
842            make_route("GET", "/users/{id}", plugin("auth")),
843        ];
844        let (result, diagnostics) = check_collisions(&routes);
845        assert_eq!(
846            result.status,
847            CheckStatus::Fail,
848            "different param names should collide: {}",
849            result.message
850        );
851        assert_eq!(diagnostics.len(), 1);
852        assert_eq!(diagnostics[0].path, "/users/{}");
853    }
854
855    #[test]
856    fn collisions_catchall_different_names_detected() {
857        let routes = vec![
858            make_route("GET", "/files/{*path}", RouteSource::User),
859            make_route("GET", "/files/{*rest}", plugin("storage")),
860        ];
861        let (result, diagnostics) = check_collisions(&routes);
862        assert_eq!(
863            result.status,
864            CheckStatus::Fail,
865            "different catch-all names should collide"
866        );
867        assert_eq!(diagnostics[0].path, "/files/{}");
868    }
869
870    #[test]
871    fn collisions_catchall_vs_named_param_detected() {
872        // matchit treats {id} and {*rest} at the same position as a conflict:
873        // inserting /src/{file} after /src/{*filepath} returns InsertError::Conflict.
874        let routes = vec![
875            make_route("GET", "/files/{id}", RouteSource::User),
876            make_route("GET", "/files/{*rest}", plugin("storage")),
877        ];
878        let (result, _) = check_collisions(&routes);
879        assert_eq!(
880            result.status,
881            CheckStatus::Fail,
882            "catch-all and named param at same position conflict in matchit"
883        );
884    }
885
886    #[test]
887    fn normalize_path_for_collision_replaces_param_names() {
888        assert_eq!(
889            normalize_path_for_collision("/users/{user_id}/posts/{post_id}"),
890            "/users/{}/posts/{}"
891        );
892        assert_eq!(normalize_path_for_collision("/files/{*rest}"), "/files/{}");
893        assert_eq!(
894            normalize_path_for_collision("/static/app.js"),
895            "/static/app.js"
896        );
897        assert_eq!(normalize_path_for_collision("/"), "/");
898    }
899
900    // ── check_sensitive_surfaces ───────────────────────────────────────────
901
902    #[test]
903    fn sensitive_no_sensitive_routes_passes() {
904        let routes = vec![
905            make_route("GET", "/posts", plugin("blog")),
906            make_route("GET", "/api/users", plugin("blog")),
907        ];
908        let result = check_sensitive_surfaces("blog", &routes, &[]);
909        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
910    }
911
912    #[test]
913    fn sensitive_admin_route_undeclared_fails() {
914        let routes = vec![make_route("GET", "/admin/dashboard", plugin("myplugin"))];
915        let result = check_sensitive_surfaces("myplugin", &routes, &[]);
916        assert_eq!(result.status, CheckStatus::Fail, "{}", result.message);
917    }
918
919    #[test]
920    fn sensitive_admin_route_declared_passes() {
921        let routes = vec![make_route("GET", "/admin/dashboard", plugin("myplugin"))];
922        let declared = vec![SensitiveRoute {
923            path_pattern: "/admin".to_owned(),
924            auth_mechanism: "Role: admin required".to_owned(),
925        }];
926        let result = check_sensitive_surfaces("myplugin", &routes, &declared);
927        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
928    }
929
930    #[test]
931    fn sensitive_debug_route_undeclared_fails() {
932        let routes = vec![make_route("GET", "/debug/state", plugin("myplugin"))];
933        let result = check_sensitive_surfaces("myplugin", &routes, &[]);
934        assert_eq!(result.status, CheckStatus::Fail);
935    }
936
937    #[test]
938    fn sensitive_empty_auth_mechanism_fails() {
939        let routes = vec![make_route("GET", "/admin/users", plugin("myplugin"))];
940        let declared = vec![SensitiveRoute {
941            path_pattern: "/admin".to_owned(),
942            auth_mechanism: String::new(),
943        }];
944        let result = check_sensitive_surfaces("myplugin", &routes, &declared);
945        assert_eq!(
946            result.status,
947            CheckStatus::Fail,
948            "empty auth_mechanism must fail"
949        );
950    }
951
952    #[test]
953    fn sensitive_only_checks_own_plugin_routes() {
954        let routes = vec![
955            make_route("GET", "/admin/panel", RouteSource::User),
956            make_route("GET", "/posts", plugin("blog")),
957        ];
958        let result = check_sensitive_surfaces("blog", &routes, &[]);
959        assert_eq!(result.status, CheckStatus::Pass);
960    }
961
962    #[test]
963    fn sensitive_credentials_keyword_detected() {
964        let routes = vec![make_route("GET", "/credential/rotate", plugin("auth"))];
965        let result = check_sensitive_surfaces("auth", &routes, &[]);
966        assert_eq!(result.status, CheckStatus::Fail);
967    }
968
969    #[test]
970    fn sensitive_metrics_keyword_detected() {
971        let routes = vec![make_route("GET", "/metrics", plugin("prom"))];
972        let result = check_sensitive_surfaces("prom", &routes, &[]);
973        assert_eq!(result.status, CheckStatus::Fail);
974    }
975
976    // ── check_duplicate_registration ──────────────────────────────────────
977
978    #[test]
979    fn duplicate_registration_no_duplicates_passes() {
980        let routes = vec![
981            make_route("GET", "/admin", plugin("admin")),
982            make_route("POST", "/admin/items", plugin("admin")),
983        ];
984        let result = check_duplicate_registration("admin", &routes);
985        assert_eq!(result.status, CheckStatus::Pass, "{}", result.message);
986    }
987
988    #[test]
989    fn duplicate_registration_same_route_twice_fails() {
990        let routes = vec![
991            make_route("GET", "/admin", plugin("admin")),
992            make_route("GET", "/admin", plugin("admin")),
993        ];
994        let result = check_duplicate_registration("admin", &routes);
995        assert_eq!(result.status, CheckStatus::Fail, "{}", result.message);
996        assert_eq!(result.diagnostics.len(), 1);
997    }
998
999    #[test]
1000    fn duplicate_registration_diagnostic_names_method_path_count() {
1001        let routes = vec![
1002            make_route("POST", "/admin/items", plugin("admin")),
1003            make_route("POST", "/admin/items", plugin("admin")),
1004            make_route("POST", "/admin/items", plugin("admin")),
1005        ];
1006        let result = check_duplicate_registration("admin", &routes);
1007        assert_eq!(result.status, CheckStatus::Fail);
1008        assert!(
1009            result.diagnostics[0].contains("POST")
1010                && result.diagnostics[0].contains("/admin/items"),
1011            "diagnostic should name route: {:?}",
1012            result.diagnostics
1013        );
1014        assert!(
1015            result.diagnostics[0].contains('3'),
1016            "diagnostic should include count: {:?}",
1017            result.diagnostics
1018        );
1019    }
1020
1021    #[test]
1022    fn duplicate_registration_no_plugin_routes_skips() {
1023        let routes = vec![make_route("GET", "/posts", RouteSource::User)];
1024        let result = check_duplicate_registration("admin", &routes);
1025        assert_eq!(result.status, CheckStatus::Skip);
1026    }
1027
1028    #[test]
1029    fn duplicate_registration_only_checks_own_plugin() {
1030        let routes = vec![
1031            make_route("GET", "/harvest/feed", plugin("harvest")),
1032            make_route("GET", "/harvest/feed", plugin("harvest")),
1033            make_route("GET", "/admin", plugin("admin")),
1034        ];
1035        let result = check_duplicate_registration("admin", &routes);
1036        assert_eq!(result.status, CheckStatus::Pass);
1037    }
1038
1039    // ── run_conformance ────────────────────────────────────────────────────
1040
1041    #[test]
1042    fn run_conformance_all_pass_when_clean() {
1043        let routes = vec![
1044            make_route("GET", "/admin", plugin("admin")),
1045            make_route("POST", "/admin/items", plugin("admin")),
1046        ];
1047        let config = ConformanceConfig::new("admin")
1048            .prefix("/admin")
1049            .sensitive_route("/admin", "Role: admin required");
1050        let report = run_conformance(&config, &routes);
1051        assert!(
1052            report.passed(),
1053            "expected pass:\n{}",
1054            report.to_text_report()
1055        );
1056    }
1057
1058    #[test]
1059    fn run_conformance_fails_on_collision() {
1060        let routes = vec![
1061            make_route("GET", "/posts", RouteSource::User),
1062            make_route("GET", "/posts", plugin("harvest")),
1063        ];
1064        let config = ConformanceConfig::new("harvest");
1065        let report = run_conformance(&config, &routes);
1066        assert!(!report.passed());
1067    }
1068
1069    #[test]
1070    fn run_conformance_report_has_plugin_name() {
1071        let config = ConformanceConfig::new("my-plugin");
1072        let report = run_conformance(&config, &[]);
1073        assert_eq!(report.plugin_name, "my-plugin");
1074    }
1075
1076    #[test]
1077    fn run_conformance_skips_prefix_check_when_none() {
1078        let routes = vec![make_route("GET", "/anywhere", plugin("myplugin"))];
1079        let config = ConformanceConfig::new("myplugin");
1080        let report = run_conformance(&config, &routes);
1081        let has_prefix_check = report.checks.iter().any(|c| c.name == "route-prefix");
1082        assert!(
1083            !has_prefix_check,
1084            "prefix check should not run when expected_prefix is None"
1085        );
1086    }
1087
1088    // ── ConformanceReport ──────────────────────────────────────────────────
1089
1090    #[test]
1091    fn report_passed_false_when_any_fail() {
1092        let report = ConformanceReport {
1093            plugin_name: "test".to_owned(),
1094            checks: vec![
1095                CheckResult {
1096                    name: "check-1".to_owned(),
1097                    status: CheckStatus::Pass,
1098                    message: "ok".to_owned(),
1099                    diagnostics: vec![],
1100                },
1101                CheckResult {
1102                    name: "check-2".to_owned(),
1103                    status: CheckStatus::Fail,
1104                    message: "fail".to_owned(),
1105                    diagnostics: vec![],
1106                },
1107            ],
1108        };
1109        assert!(!report.passed());
1110    }
1111
1112    #[test]
1113    fn report_passed_true_with_skip() {
1114        let report = ConformanceReport {
1115            plugin_name: "test".to_owned(),
1116            checks: vec![CheckResult {
1117                name: "route-prefix".to_owned(),
1118                status: CheckStatus::Skip,
1119                message: "no routes".to_owned(),
1120                diagnostics: vec![],
1121            }],
1122        };
1123        assert!(report.passed(), "Skip should not fail the report");
1124    }
1125
1126    #[test]
1127    fn report_text_contains_plugin_name() {
1128        let report = ConformanceReport {
1129            plugin_name: "autumn-admin-plugin".to_owned(),
1130            checks: vec![],
1131        };
1132        assert!(report.to_text_report().contains("autumn-admin-plugin"));
1133    }
1134
1135    #[test]
1136    fn report_text_shows_fail_when_failed() {
1137        let report = ConformanceReport {
1138            plugin_name: "test".to_owned(),
1139            checks: vec![CheckResult {
1140                name: "route-collision".to_owned(),
1141                status: CheckStatus::Fail,
1142                message: "1 collision".to_owned(),
1143                diagnostics: vec![],
1144            }],
1145        };
1146        assert!(report.to_text_report().contains("FAIL"));
1147    }
1148
1149    #[test]
1150    fn report_text_shows_pass_when_all_pass() {
1151        let report = ConformanceReport {
1152            plugin_name: "test".to_owned(),
1153            checks: vec![],
1154        };
1155        assert!(report.to_text_report().contains("PASS"));
1156    }
1157
1158    #[test]
1159    fn report_serializes_to_json() {
1160        let report = ConformanceReport {
1161            plugin_name: "test-plugin".to_owned(),
1162            checks: vec![CheckResult {
1163                name: "route-attribution".to_owned(),
1164                status: CheckStatus::Pass,
1165                message: "ok".to_owned(),
1166                diagnostics: vec![],
1167            }],
1168        };
1169        let json = serde_json::to_string(&report).unwrap();
1170        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1171        assert_eq!(parsed["plugin_name"], "test-plugin");
1172        assert_eq!(parsed["checks"][0]["status"], "pass");
1173    }
1174
1175    #[test]
1176    fn report_deserializes_from_json() {
1177        let json = r#"{"plugin_name":"test","checks":[{"name":"route-attribution","status":"pass","message":"ok","diagnostics":[]}]}"#;
1178        let report: ConformanceReport = serde_json::from_str(json).unwrap();
1179        assert_eq!(report.plugin_name, "test");
1180        assert_eq!(report.checks[0].status, CheckStatus::Pass);
1181    }
1182
1183    // ── is_sensitive_path ──────────────────────────────────────────────────
1184
1185    #[test]
1186    fn admin_path_is_sensitive() {
1187        assert!(is_sensitive_path("/admin"));
1188        assert!(is_sensitive_path("/admin/users"));
1189    }
1190
1191    #[test]
1192    fn debug_path_is_sensitive() {
1193        assert!(is_sensitive_path("/debug"));
1194        assert!(is_sensitive_path("/api/debug/state"));
1195    }
1196
1197    #[test]
1198    fn posts_path_is_not_sensitive() {
1199        assert!(!is_sensitive_path("/posts"));
1200        assert!(!is_sensitive_path("/api/users"));
1201    }
1202
1203    #[test]
1204    fn path_with_admin_as_substring_of_longer_segment_not_sensitive() {
1205        // "administrator" as a whole segment IS caught (starts_with("admin"))
1206        // but this is intentional per the spec: admin-prefixed segments are sensitive
1207        assert!(!is_sensitive_path("/products/admiration"));
1208    }
1209}