proptest_redirect.rs 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. //! `test(proptest_redirect)` — redirect-matcher property suite (task 42).
  2. //!
  3. //! Pure properties over `protocol::redirect_repo::match_redirect_uri`
  4. //! (RFC 3986 §6.2.1 Simple String Comparison + RFC 8252 §7.3 loopback):
  5. //! exact self-match, empty-set rejection, `Exact` implies membership,
  6. //! `Loopback` implies a loopback request shape, and `Exact` outranks
  7. //! `Loopback`. No `unwrap`/`expect`/`panic` anywhere.
  8. use d_oauth::protocol::redirect_repo::{RedirectDecision, match_redirect_uri};
  9. use proptest::prelude::*;
  10. /// Well-formed registered redirect URIs (no userinfo/query/fragment).
  11. ///
  12. /// The dots are regex-escaped: an unescaped `.` would generate arbitrary
  13. /// characters (including `?`/`#`/`@`), producing registrations the matcher
  14. /// must fail closed on instead of matching.
  15. fn registered_uri() -> impl Strategy<Value = String> {
  16. (
  17. "https://client\\.example\\.test/cb/[a-z0-9]{0,8}",
  18. "[a-z0-9]{0,6}",
  19. )
  20. .prop_map(|(base, tail)| {
  21. if tail.is_empty() {
  22. base
  23. } else {
  24. format!("{base}/{tail}")
  25. }
  26. })
  27. }
  28. /// Whether `requested` has the loopback request shape the matcher demands:
  29. /// `http` scheme (any ASCII case), loopback-literal host, no userinfo,
  30. /// query, or fragment (mirrors `parse_uri` + the §7.3 arm, not its code).
  31. fn is_loopback_shaped(requested: &str) -> bool {
  32. let Some((scheme, rest)) = requested.split_once(':') else {
  33. return false;
  34. };
  35. if !scheme.eq_ignore_ascii_case("http") || !rest.starts_with("//") {
  36. return false;
  37. }
  38. let authority = &rest[2..];
  39. let host = authority.split([':', '/']).next().unwrap_or("");
  40. if host != "127.0.0.1" && host != "[::1]" {
  41. return false;
  42. }
  43. !requested.contains(['?', '#', '@'])
  44. }
  45. proptest! {
  46. #[test]
  47. fn redirect_exact_self_match(uri in registered_uri()) {
  48. prop_assert_eq!(
  49. match_redirect_uri(std::slice::from_ref(&uri), &uri),
  50. RedirectDecision::Exact
  51. );
  52. }
  53. #[test]
  54. fn redirect_empty_set_always_rejects(requested in "\\PC*") {
  55. let empty: Vec<String> = vec![];
  56. prop_assert_eq!(
  57. match_redirect_uri(&empty, &requested),
  58. RedirectDecision::Reject
  59. );
  60. }
  61. #[test]
  62. fn redirect_exact_implies_membership(
  63. registered in proptest::collection::vec(registered_uri(), 0..4),
  64. requested in "\\PC*",
  65. ) {
  66. if match_redirect_uri(&registered, &requested) == RedirectDecision::Exact {
  67. prop_assert!(registered.iter().any(|candidate| candidate == &requested));
  68. }
  69. }
  70. #[test]
  71. fn redirect_loopback_implies_loopback_request_shape(
  72. registered in proptest::collection::vec(registered_uri(), 0..4),
  73. requested in "\\PC*",
  74. ) {
  75. if match_redirect_uri(&registered, &requested) == RedirectDecision::Loopback {
  76. prop_assert!(
  77. is_loopback_shaped(&requested),
  78. "loopback match needs a loopback request: {:?}",
  79. requested
  80. );
  81. }
  82. }
  83. #[test]
  84. fn redirect_exact_outranks_loopback(port in 1024..65535_u16, path in "[a-z]{1,8}") {
  85. let loopback_reg = "http://127.0.0.1/cb".to_owned();
  86. let exact = format!("https://client.example.test/{path}");
  87. let requested_loopback = format!("http://127.0.0.1:{port}/cb");
  88. // The exact registration wins when it matches byte-for-byte, even with
  89. // a loopback candidate that would also match its own request shape.
  90. prop_assert_eq!(
  91. match_redirect_uri(&[loopback_reg.clone(), exact.clone()], &exact),
  92. RedirectDecision::Exact
  93. );
  94. // And the loopback arm still fires for the port-flexible request.
  95. prop_assert_eq!(
  96. match_redirect_uri(&[loopback_reg, exact], &requested_loopback),
  97. RedirectDecision::Loopback
  98. );
  99. }
  100. }