1use serde::{Deserialize, Serialize};
22
23use crate::route_listing::{RouteInfo, RouteSource};
24
25#[derive(Debug, Clone)]
29pub struct ConformanceConfig {
30 pub plugin_name: String,
32 pub expected_prefix: Option<String>,
35 pub intentional_root_routes: Vec<String>,
38 pub sensitive_routes: Vec<SensitiveRoute>,
42}
43
44impl ConformanceConfig {
45 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 #[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 #[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 #[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#[derive(Debug, Clone)]
91pub struct SensitiveRoute {
92 pub path_pattern: String,
94 pub auth_mechanism: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum CheckStatus {
105 Pass,
107 Fail,
109 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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CheckResult {
126 pub name: String,
128 pub status: CheckStatus,
130 pub message: String,
132 pub diagnostics: Vec<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CollisionDiagnostic {
139 pub method: String,
141 pub path: String,
143 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#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct RouteContributor {
166 pub source: String,
168 pub handler: String,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ConformanceReport {
175 pub plugin_name: String,
177 pub checks: Vec<CheckResult>,
179}
180
181impl ConformanceReport {
182 #[must_use]
185 pub fn passed(&self) -> bool {
186 self.checks.iter().all(|c| c.status != CheckStatus::Fail)
187 }
188
189 #[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
238const SENSITIVE_KEYWORDS: &[&str] = &[
241 "admin",
242 "debug",
243 "credential",
244 "operator",
245 "secret",
246 "metrics",
247];
248
249fn 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#[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#[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
348fn 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
366pub 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#[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#[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#[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#[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 assert!(!is_sensitive_path("/products/admiration"));
1208 }
1209}