spikard 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -6
  3. data/ext/spikard_rb/Cargo.toml +2 -2
  4. data/lib/spikard/app.rb +33 -14
  5. data/lib/spikard/testing.rb +47 -12
  6. data/lib/spikard/version.rb +1 -1
  7. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
  8. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -0
  9. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -0
  10. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
  11. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
  12. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -0
  13. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -0
  14. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -0
  15. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -0
  16. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -0
  17. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -0
  18. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
  19. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -0
  20. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -0
  21. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -0
  22. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -0
  23. data/vendor/crates/spikard-core/Cargo.toml +4 -4
  24. data/vendor/crates/spikard-core/src/debug.rs +64 -0
  25. data/vendor/crates/spikard-core/src/di/container.rs +3 -27
  26. data/vendor/crates/spikard-core/src/di/factory.rs +1 -5
  27. data/vendor/crates/spikard-core/src/di/graph.rs +8 -47
  28. data/vendor/crates/spikard-core/src/di/mod.rs +1 -1
  29. data/vendor/crates/spikard-core/src/di/resolved.rs +1 -7
  30. data/vendor/crates/spikard-core/src/di/value.rs +2 -4
  31. data/vendor/crates/spikard-core/src/errors.rs +30 -0
  32. data/vendor/crates/spikard-core/src/http.rs +262 -0
  33. data/vendor/crates/spikard-core/src/lib.rs +1 -1
  34. data/vendor/crates/spikard-core/src/lifecycle.rs +764 -0
  35. data/vendor/crates/spikard-core/src/metadata.rs +389 -0
  36. data/vendor/crates/spikard-core/src/parameters.rs +1962 -159
  37. data/vendor/crates/spikard-core/src/problem.rs +34 -0
  38. data/vendor/crates/spikard-core/src/request_data.rs +966 -1
  39. data/vendor/crates/spikard-core/src/router.rs +263 -2
  40. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +688 -0
  41. data/vendor/crates/spikard-core/src/{validation.rs → validation/mod.rs} +26 -268
  42. data/vendor/crates/spikard-http/Cargo.toml +12 -16
  43. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -0
  44. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -0
  45. data/vendor/crates/spikard-http/src/auth.rs +65 -16
  46. data/vendor/crates/spikard-http/src/background.rs +1614 -3
  47. data/vendor/crates/spikard-http/src/cors.rs +515 -0
  48. data/vendor/crates/spikard-http/src/debug.rs +65 -0
  49. data/vendor/crates/spikard-http/src/di_handler.rs +1322 -77
  50. data/vendor/crates/spikard-http/src/handler_response.rs +711 -0
  51. data/vendor/crates/spikard-http/src/handler_trait.rs +607 -5
  52. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +6 -0
  53. data/vendor/crates/spikard-http/src/lib.rs +33 -28
  54. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +81 -0
  55. data/vendor/crates/spikard-http/src/lifecycle.rs +765 -0
  56. data/vendor/crates/spikard-http/src/middleware/mod.rs +372 -117
  57. data/vendor/crates/spikard-http/src/middleware/multipart.rs +836 -10
  58. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +409 -43
  59. data/vendor/crates/spikard-http/src/middleware/validation.rs +513 -65
  60. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +345 -0
  61. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1055 -0
  62. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +473 -3
  63. data/vendor/crates/spikard-http/src/query_parser.rs +455 -31
  64. data/vendor/crates/spikard-http/src/response.rs +321 -0
  65. data/vendor/crates/spikard-http/src/server/handler.rs +1572 -9
  66. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +136 -0
  67. data/vendor/crates/spikard-http/src/server/mod.rs +875 -178
  68. data/vendor/crates/spikard-http/src/server/request_extraction.rs +674 -23
  69. data/vendor/crates/spikard-http/src/server/routing_factory.rs +599 -0
  70. data/vendor/crates/spikard-http/src/sse.rs +983 -21
  71. data/vendor/crates/spikard-http/src/testing/form.rs +38 -0
  72. data/vendor/crates/spikard-http/src/testing/test_client.rs +0 -2
  73. data/vendor/crates/spikard-http/src/testing.rs +7 -7
  74. data/vendor/crates/spikard-http/src/websocket.rs +1055 -4
  75. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -0
  76. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -0
  77. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -0
  78. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -0
  79. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -0
  80. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -0
  81. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -0
  82. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -0
  83. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -0
  84. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -0
  85. data/vendor/crates/spikard-rb/Cargo.toml +10 -4
  86. data/vendor/crates/spikard-rb/build.rs +196 -5
  87. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
  88. data/vendor/crates/spikard-rb/src/{config.rs → config/server_config.rs} +100 -109
  89. data/vendor/crates/spikard-rb/src/conversion.rs +121 -20
  90. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -0
  91. data/vendor/crates/spikard-rb/src/{di.rs → di/mod.rs} +12 -46
  92. data/vendor/crates/spikard-rb/src/handler.rs +100 -107
  93. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
  94. data/vendor/crates/spikard-rb/src/lib.rs +467 -1428
  95. data/vendor/crates/spikard-rb/src/lifecycle.rs +1 -0
  96. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
  97. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +447 -0
  98. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
  99. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -0
  100. data/vendor/crates/spikard-rb/src/server.rs +47 -22
  101. data/vendor/crates/spikard-rb/src/{test_client.rs → testing/client.rs} +187 -40
  102. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
  103. data/vendor/crates/spikard-rb/src/testing/websocket.rs +635 -0
  104. data/vendor/crates/spikard-rb/src/websocket.rs +178 -37
  105. metadata +46 -13
  106. data/vendor/crates/spikard-http/src/parameters.rs +0 -1
  107. data/vendor/crates/spikard-http/src/problem.rs +0 -1
  108. data/vendor/crates/spikard-http/src/router.rs +0 -1
  109. data/vendor/crates/spikard-http/src/schema_registry.rs +0 -1
  110. data/vendor/crates/spikard-http/src/type_hints.rs +0 -1
  111. data/vendor/crates/spikard-http/src/validation.rs +0 -1
  112. data/vendor/crates/spikard-rb/src/test_websocket.rs +0 -221
  113. /data/vendor/crates/spikard-rb/src/{test_sse.rs → testing/sse.rs} +0 -0
@@ -487,4 +487,519 @@ mod tests {
487
487
  let result = validate_cors_request(&headers, &config);
488
488
  assert!(result.is_ok());
489
489
  }
490
+
491
+ // SECURITY TESTS: CORS Attack Vectors
492
+
493
+ /// SECURITY TEST: Verify credentials=true with wildcard is caught
494
+ /// This is a critical vulnerability - RFC 6454 forbids this
495
+ #[test]
496
+ fn test_credentials_with_wildcard_config_is_security_issue() {
497
+ let config = CorsConfig {
498
+ allowed_origins: vec!["*".to_string()],
499
+ allowed_methods: vec!["GET".to_string()],
500
+ allowed_headers: vec![],
501
+ expose_headers: None,
502
+ max_age: None,
503
+ allow_credentials: Some(true), // SECURITY BUG: This should not be allowed with wildcard
504
+ };
505
+
506
+ let mut headers = HeaderMap::new();
507
+ headers.insert("origin", HeaderValue::from_static("https://evil.com"));
508
+ headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
509
+
510
+ let result = handle_preflight(&headers, &config);
511
+
512
+ // BUG: This should return 500 or reject the config, but instead succeeds
513
+ if let Ok(response) = result {
514
+ let resp_headers = response.headers();
515
+ let has_credentials = resp_headers
516
+ .get("access-control-allow-credentials")
517
+ .map(|v| v == "true")
518
+ .unwrap_or(false);
519
+ let origin_header = resp_headers.get("access-control-allow-origin");
520
+
521
+ if has_credentials && origin_header.is_some() {
522
+ let origin_val = origin_header.unwrap().to_str().unwrap_or("");
523
+ if origin_val == "*" {
524
+ panic!("SECURITY VULNERABILITY: credentials=true with origin=* allowed");
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ /// SECURITY TEST: Exact origin matching required
531
+ /// Subdomain like api.evil.example.com must NOT match example.com
532
+ #[test]
533
+ fn test_subdomain_bypass_blocked() {
534
+ let config = CorsConfig {
535
+ allowed_origins: vec!["https://example.com".to_string()],
536
+ allowed_methods: vec!["GET".to_string()],
537
+ allowed_headers: vec![],
538
+ expose_headers: None,
539
+ max_age: None,
540
+ allow_credentials: None,
541
+ };
542
+
543
+ assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
544
+ assert!(!is_origin_allowed("https://evil.example.com", &config.allowed_origins));
545
+ assert!(!is_origin_allowed(
546
+ "https://sub.sub.example.com",
547
+ &config.allowed_origins
548
+ ));
549
+
550
+ assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
551
+ }
552
+
553
+ /// SECURITY TEST: Port exact matching required
554
+ /// localhost:3001 must NOT match localhost:3000
555
+ #[test]
556
+ fn test_port_bypass_blocked() {
557
+ let config = CorsConfig {
558
+ allowed_origins: vec!["http://localhost:3000".to_string()],
559
+ allowed_methods: vec!["GET".to_string()],
560
+ allowed_headers: vec![],
561
+ expose_headers: None,
562
+ max_age: None,
563
+ allow_credentials: None,
564
+ };
565
+
566
+ assert!(!is_origin_allowed("http://localhost:3001", &config.allowed_origins));
567
+ assert!(!is_origin_allowed("http://localhost:8080", &config.allowed_origins));
568
+ assert!(!is_origin_allowed("http://localhost:443", &config.allowed_origins));
569
+
570
+ assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
571
+ }
572
+
573
+ /// SECURITY TEST: Protocol exact matching required
574
+ /// http://example.com must NOT match https://example.com
575
+ #[test]
576
+ fn test_protocol_downgrade_attack_blocked() {
577
+ let config = CorsConfig {
578
+ allowed_origins: vec!["https://example.com".to_string()],
579
+ allowed_methods: vec!["GET".to_string()],
580
+ allowed_headers: vec![],
581
+ expose_headers: None,
582
+ max_age: None,
583
+ allow_credentials: None,
584
+ };
585
+
586
+ assert!(!is_origin_allowed("http://example.com", &config.allowed_origins));
587
+ assert!(!is_origin_allowed("ws://example.com", &config.allowed_origins));
588
+ assert!(!is_origin_allowed("wss://example.com", &config.allowed_origins));
589
+
590
+ assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
591
+ }
592
+
593
+ /// SECURITY TEST: Case sensitivity in origin matching
594
+ /// Origins should match exactly (including case)
595
+ #[test]
596
+ fn test_case_sensitive_origin_matching() {
597
+ let config = CorsConfig {
598
+ allowed_origins: vec!["https://Example.Com".to_string()],
599
+ allowed_methods: vec!["GET".to_string()],
600
+ allowed_headers: vec![],
601
+ expose_headers: None,
602
+ max_age: None,
603
+ allow_credentials: None,
604
+ };
605
+
606
+ assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
607
+ assert!(!is_origin_allowed("https://EXAMPLE.COM", &config.allowed_origins));
608
+
609
+ assert!(is_origin_allowed("https://Example.Com", &config.allowed_origins));
610
+ }
611
+
612
+ /// SECURITY TEST: Trailing slash normalization
613
+ /// https://example.com/ should be treated differently from https://example.com
614
+ #[test]
615
+ fn test_trailing_slash_origin_not_normalized() {
616
+ let config = CorsConfig {
617
+ allowed_origins: vec!["https://example.com".to_string()],
618
+ allowed_methods: vec!["GET".to_string()],
619
+ allowed_headers: vec![],
620
+ expose_headers: None,
621
+ max_age: None,
622
+ allow_credentials: None,
623
+ };
624
+
625
+ assert!(!is_origin_allowed("https://example.com/", &config.allowed_origins));
626
+
627
+ assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
628
+ }
629
+
630
+ /// SECURITY TEST: NULL origin and wildcard behavior
631
+ /// Special "null" origin used by file:// and sandboxed iframes
632
+ /// The current implementation treats "null" as a regular origin string,
633
+ /// which means it IS allowed by wildcard (not ideal but documents current behavior)
634
+ #[test]
635
+ fn test_null_origin_with_wildcard() {
636
+ let config = CorsConfig {
637
+ allowed_origins: vec!["*".to_string()],
638
+ allowed_methods: vec!["GET".to_string()],
639
+ allowed_headers: vec![],
640
+ expose_headers: None,
641
+ max_age: None,
642
+ allow_credentials: None,
643
+ };
644
+
645
+ // SECURITY NOTE: "null" origin is allowed by wildcard in current implementation
646
+ assert!(is_origin_allowed("null", &config.allowed_origins));
647
+
648
+ let with_explicit_null = CorsConfig {
649
+ allowed_origins: vec!["null".to_string()],
650
+ allowed_methods: vec!["GET".to_string()],
651
+ allowed_headers: vec![],
652
+ expose_headers: None,
653
+ max_age: None,
654
+ allow_credentials: None,
655
+ };
656
+ assert!(is_origin_allowed("null", &with_explicit_null.allowed_origins));
657
+ }
658
+
659
+ /// SECURITY TEST: Empty origin is always rejected
660
+ #[test]
661
+ fn test_empty_origin_always_rejected() {
662
+ let config_with_wildcard = CorsConfig {
663
+ allowed_origins: vec!["*".to_string()],
664
+ allowed_methods: vec!["GET".to_string()],
665
+ allowed_headers: vec![],
666
+ expose_headers: None,
667
+ max_age: None,
668
+ allow_credentials: None,
669
+ };
670
+ assert!(!is_origin_allowed("", &config_with_wildcard.allowed_origins));
671
+
672
+ let config_with_explicit = CorsConfig {
673
+ allowed_origins: vec!["https://example.com".to_string()],
674
+ allowed_methods: vec!["GET".to_string()],
675
+ allowed_headers: vec![],
676
+ expose_headers: None,
677
+ max_age: None,
678
+ allow_credentials: None,
679
+ };
680
+ assert!(!is_origin_allowed("", &config_with_explicit.allowed_origins));
681
+ }
682
+
683
+ /// SECURITY TEST: Preflight with invalid origin should reject
684
+ #[test]
685
+ fn test_preflight_rejects_invalid_origin() {
686
+ let config = make_cors_config();
687
+ let mut headers = HeaderMap::new();
688
+ headers.insert("origin", HeaderValue::from_static("https://untrusted.com"));
689
+ headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
690
+
691
+ let result = handle_preflight(&headers, &config);
692
+ assert!(result.is_err());
693
+
694
+ let response = *result.unwrap_err();
695
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
696
+ }
697
+
698
+ /// SECURITY TEST: Multiple origins - each must be exact match
699
+ #[test]
700
+ fn test_multiple_origins_exact_matching() {
701
+ let config = CorsConfig {
702
+ allowed_origins: vec!["https://trusted1.com".to_string(), "https://trusted2.com".to_string()],
703
+ allowed_methods: vec!["GET".to_string()],
704
+ allowed_headers: vec![],
705
+ expose_headers: None,
706
+ max_age: None,
707
+ allow_credentials: None,
708
+ };
709
+
710
+ assert!(is_origin_allowed("https://trusted1.com", &config.allowed_origins));
711
+ assert!(is_origin_allowed("https://trusted2.com", &config.allowed_origins));
712
+
713
+ assert!(!is_origin_allowed(
714
+ "https://trusted1.com.evil.com",
715
+ &config.allowed_origins
716
+ ));
717
+ assert!(!is_origin_allowed("https://trusted3.com", &config.allowed_origins));
718
+ assert!(!is_origin_allowed("https://trusted.com", &config.allowed_origins));
719
+ }
720
+
721
+ /// SECURITY TEST: Wildcard origin should allow any origin (but check config)
722
+ #[test]
723
+ fn test_wildcard_allows_all_but_check_credentials() {
724
+ let config = CorsConfig {
725
+ allowed_origins: vec!["*".to_string()],
726
+ allowed_methods: vec!["GET".to_string()],
727
+ allowed_headers: vec![],
728
+ expose_headers: None,
729
+ max_age: None,
730
+ allow_credentials: None,
731
+ };
732
+
733
+ assert!(is_origin_allowed("https://example.com", &config.allowed_origins));
734
+ assert!(is_origin_allowed("https://evil.com", &config.allowed_origins));
735
+ assert!(is_origin_allowed("http://localhost:3000", &config.allowed_origins));
736
+
737
+ assert!(!is_origin_allowed("", &config.allowed_origins));
738
+ }
739
+
740
+ /// SECURITY TEST: Preflight response headers must match config exactly
741
+ #[test]
742
+ fn test_preflight_response_has_correct_allowed_origins() {
743
+ let config = CorsConfig {
744
+ allowed_origins: vec!["https://trusted.com".to_string()],
745
+ allowed_methods: vec!["GET".to_string(), "POST".to_string()],
746
+ allowed_headers: vec!["content-type".to_string()],
747
+ expose_headers: None,
748
+ max_age: Some(3600),
749
+ allow_credentials: Some(false),
750
+ };
751
+
752
+ let mut headers = HeaderMap::new();
753
+ headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
754
+ headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
755
+ headers.insert(
756
+ "access-control-request-headers",
757
+ HeaderValue::from_static("content-type"),
758
+ );
759
+
760
+ let result = handle_preflight(&headers, &config);
761
+ assert!(result.is_ok());
762
+
763
+ let response = result.unwrap();
764
+ let resp_headers = response.headers();
765
+
766
+ assert_eq!(
767
+ resp_headers.get("access-control-allow-origin").unwrap(),
768
+ "https://trusted.com"
769
+ );
770
+
771
+ assert!(
772
+ resp_headers
773
+ .get("access-control-allow-methods")
774
+ .unwrap()
775
+ .to_str()
776
+ .unwrap()
777
+ .contains("GET")
778
+ );
779
+ assert!(
780
+ resp_headers
781
+ .get("access-control-allow-methods")
782
+ .unwrap()
783
+ .to_str()
784
+ .unwrap()
785
+ .contains("POST")
786
+ );
787
+
788
+ assert!(resp_headers.get("access-control-allow-credentials").is_none());
789
+ }
790
+
791
+ /// SECURITY TEST: Origin not in allowed list must be rejected in preflight
792
+ #[test]
793
+ fn test_preflight_all_origins_require_validation() {
794
+ let config = CorsConfig {
795
+ allowed_origins: vec!["https://trusted.com".to_string()],
796
+ allowed_methods: vec!["GET".to_string()],
797
+ allowed_headers: vec![],
798
+ expose_headers: None,
799
+ max_age: None,
800
+ allow_credentials: None,
801
+ };
802
+
803
+ let test_cases = vec![
804
+ "https://trusted.com",
805
+ "https://evil.com",
806
+ "https://trusted.com.evil",
807
+ "http://trusted.com",
808
+ "",
809
+ ];
810
+
811
+ for origin in test_cases {
812
+ let mut headers = HeaderMap::new();
813
+ headers.insert(
814
+ "origin",
815
+ HeaderValue::from_str(origin).unwrap_or_else(|_| HeaderValue::from_static("")),
816
+ );
817
+ headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
818
+
819
+ let result = handle_preflight(&headers, &config);
820
+
821
+ if origin == "https://trusted.com" {
822
+ assert!(result.is_ok(), "Valid origin {} should be allowed", origin);
823
+ } else {
824
+ assert!(result.is_err(), "Invalid origin {} should be rejected", origin);
825
+ }
826
+ }
827
+ }
828
+
829
+ /// SECURITY TEST: Requested headers must be in allowed list
830
+ #[test]
831
+ fn test_preflight_validates_all_requested_headers() {
832
+ let config = CorsConfig {
833
+ allowed_origins: vec!["https://trusted.com".to_string()],
834
+ allowed_methods: vec!["POST".to_string()],
835
+ allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
836
+ expose_headers: None,
837
+ max_age: None,
838
+ allow_credentials: None,
839
+ };
840
+
841
+ let test_cases = vec![
842
+ ("content-type", true),
843
+ ("authorization", true),
844
+ ("content-type, authorization", true),
845
+ ("x-custom-header", false),
846
+ ("content-type, x-custom", false),
847
+ ];
848
+
849
+ for (headers_str, should_pass) in test_cases {
850
+ let mut headers = HeaderMap::new();
851
+ headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
852
+ headers.insert("access-control-request-method", HeaderValue::from_static("POST"));
853
+ headers.insert(
854
+ "access-control-request-headers",
855
+ HeaderValue::from_str(headers_str).unwrap(),
856
+ );
857
+
858
+ let result = handle_preflight(&headers, &config);
859
+
860
+ if should_pass {
861
+ assert!(
862
+ result.is_ok(),
863
+ "Preflight with valid headers '{}' should pass",
864
+ headers_str
865
+ );
866
+ } else {
867
+ assert!(
868
+ result.is_err(),
869
+ "Preflight with invalid headers '{}' should fail",
870
+ headers_str
871
+ );
872
+ }
873
+ }
874
+ }
875
+
876
+ /// SECURITY TEST: add_cors_headers should respect origin validation
877
+ #[test]
878
+ fn test_add_cors_headers_respects_origin() {
879
+ let config = CorsConfig {
880
+ allowed_origins: vec!["https://trusted.com".to_string()],
881
+ allowed_methods: vec!["GET".to_string()],
882
+ allowed_headers: vec![],
883
+ expose_headers: Some(vec!["x-custom".to_string()]),
884
+ max_age: None,
885
+ allow_credentials: Some(true),
886
+ };
887
+
888
+ let mut response = Response::new(Body::empty());
889
+
890
+ add_cors_headers(&mut response, "https://trusted.com", &config);
891
+
892
+ let headers = response.headers();
893
+ assert_eq!(
894
+ headers.get("access-control-allow-origin").unwrap(),
895
+ "https://trusted.com"
896
+ );
897
+ assert_eq!(headers.get("access-control-expose-headers").unwrap(), "x-custom");
898
+ assert_eq!(headers.get("access-control-allow-credentials").unwrap(), "true");
899
+ }
900
+
901
+ /// SECURITY TEST: validate_cors_request respects allowed origins
902
+ #[test]
903
+ fn test_validate_cors_request_origin_must_match() {
904
+ let config = CorsConfig {
905
+ allowed_origins: vec!["https://trusted.com".to_string()],
906
+ allowed_methods: vec!["GET".to_string()],
907
+ allowed_headers: vec![],
908
+ expose_headers: None,
909
+ max_age: None,
910
+ allow_credentials: None,
911
+ };
912
+
913
+ let mut headers = HeaderMap::new();
914
+ headers.insert("origin", HeaderValue::from_static("https://trusted.com"));
915
+ assert!(validate_cors_request(&headers, &config).is_ok());
916
+
917
+ let mut headers = HeaderMap::new();
918
+ headers.insert("origin", HeaderValue::from_static("https://evil.com"));
919
+ assert!(validate_cors_request(&headers, &config).is_err());
920
+
921
+ let headers = HeaderMap::new();
922
+ assert!(validate_cors_request(&headers, &config).is_ok());
923
+ }
924
+
925
+ /// SECURITY TEST: Preflight without requested method should fail
926
+ #[test]
927
+ fn test_preflight_requires_access_control_request_method() {
928
+ let config = make_cors_config();
929
+ let mut headers = HeaderMap::new();
930
+ headers.insert("origin", HeaderValue::from_static("https://example.com"));
931
+
932
+ let result = handle_preflight(&headers, &config);
933
+ assert!(result.is_ok());
934
+ }
935
+
936
+ /// SECURITY TEST: Case-insensitive method matching
937
+ #[test]
938
+ fn test_preflight_method_case_insensitive() {
939
+ let config = CorsConfig {
940
+ allowed_origins: vec!["https://example.com".to_string()],
941
+ allowed_methods: vec!["GET".to_string(), "POST".to_string()],
942
+ allowed_headers: vec![],
943
+ expose_headers: None,
944
+ max_age: None,
945
+ allow_credentials: None,
946
+ };
947
+
948
+ let test_cases = vec!["GET", "get", "Get", "POST", "post"];
949
+
950
+ for method in test_cases {
951
+ let mut headers = HeaderMap::new();
952
+ headers.insert("origin", HeaderValue::from_static("https://example.com"));
953
+ headers.insert("access-control-request-method", HeaderValue::from_str(method).unwrap());
954
+
955
+ let result = handle_preflight(&headers, &config);
956
+ assert!(
957
+ result.is_ok(),
958
+ "Method '{}' should be allowed (case-insensitive)",
959
+ method
960
+ );
961
+ }
962
+ }
963
+
964
+ /// SECURITY TEST: Ensure preflight max-age is set correctly
965
+ #[test]
966
+ fn test_preflight_max_age_header() {
967
+ let config = CorsConfig {
968
+ allowed_origins: vec!["https://example.com".to_string()],
969
+ allowed_methods: vec!["GET".to_string()],
970
+ allowed_headers: vec![],
971
+ expose_headers: None,
972
+ max_age: Some(7200),
973
+ allow_credentials: None,
974
+ };
975
+
976
+ let mut headers = HeaderMap::new();
977
+ headers.insert("origin", HeaderValue::from_static("https://example.com"));
978
+ headers.insert("access-control-request-method", HeaderValue::from_static("GET"));
979
+
980
+ let result = handle_preflight(&headers, &config);
981
+ assert!(result.is_ok());
982
+
983
+ let response = result.unwrap();
984
+ assert_eq!(response.headers().get("access-control-max-age").unwrap(), "7200");
985
+ }
986
+
987
+ /// SECURITY TEST: Wildcard partial patterns should not work
988
+ /// *.example.com style patterns are not supported (good!)
989
+ #[test]
990
+ fn test_wildcard_patterns_not_supported() {
991
+ let config = CorsConfig {
992
+ allowed_origins: vec!["*.example.com".to_string()],
993
+ allowed_methods: vec!["GET".to_string()],
994
+ allowed_headers: vec![],
995
+ expose_headers: None,
996
+ max_age: None,
997
+ allow_credentials: None,
998
+ };
999
+
1000
+ assert!(!is_origin_allowed("https://api.example.com", &config.allowed_origins));
1001
+ assert!(!is_origin_allowed("https://example.com", &config.allowed_origins));
1002
+
1003
+ assert!(is_origin_allowed("*.example.com", &config.allowed_origins));
1004
+ }
490
1005
  }
@@ -61,3 +61,68 @@ macro_rules! debug_log_value {
61
61
  }
62
62
  };
63
63
  }
64
+
65
+ #[cfg(test)]
66
+ mod tests {
67
+ use super::*;
68
+ use std::sync::Mutex;
69
+ use std::sync::atomic::Ordering;
70
+
71
+ static FLAG_LOCK: Mutex<()> = Mutex::new(());
72
+
73
+ struct DebugFlagGuard {
74
+ previous_flag: bool,
75
+ previous_env: Option<String>,
76
+ }
77
+
78
+ impl Drop for DebugFlagGuard {
79
+ fn drop(&mut self) {
80
+ DEBUG_ENABLED.store(self.previous_flag, Ordering::Relaxed);
81
+ if let Some(prev) = &self.previous_env {
82
+ unsafe { std::env::set_var("SPIKARD_DEBUG", prev) };
83
+ } else {
84
+ unsafe { std::env::remove_var("SPIKARD_DEBUG") };
85
+ }
86
+ }
87
+ }
88
+
89
+ #[test]
90
+ fn init_enables_debug_when_requested() {
91
+ let _lock = FLAG_LOCK.lock().unwrap();
92
+ let previous = DEBUG_ENABLED.load(Ordering::Relaxed);
93
+ let previous_env = std::env::var("SPIKARD_DEBUG").ok();
94
+ let _guard = DebugFlagGuard {
95
+ previous_flag: previous,
96
+ previous_env,
97
+ };
98
+
99
+ unsafe { std::env::set_var("SPIKARD_DEBUG", "1") };
100
+
101
+ init();
102
+
103
+ assert!(is_enabled(), "init should enable debug in test builds");
104
+ }
105
+
106
+ #[test]
107
+ fn macros_respect_debug_flag() {
108
+ let _lock = FLAG_LOCK.lock().unwrap();
109
+ let previous = DEBUG_ENABLED.load(Ordering::Relaxed);
110
+ let previous_env = std::env::var("SPIKARD_DEBUG").ok();
111
+ let _guard = DebugFlagGuard {
112
+ previous_flag: previous,
113
+ previous_env,
114
+ };
115
+
116
+ DEBUG_ENABLED.store(false, Ordering::Relaxed);
117
+ debug_log!("should not print while disabled");
118
+ debug_log_module!("middleware", "disabled branch");
119
+ debug_log_value!("key", 1_u8);
120
+ assert!(!is_enabled());
121
+
122
+ DEBUG_ENABLED.store(true, Ordering::Relaxed);
123
+ debug_log!("now printing {}", 2);
124
+ debug_log_module!("router", "enabled branch");
125
+ debug_log_value!("value", 3_i32);
126
+ assert!(is_enabled());
127
+ }
128
+ }