proptest_redirect.rs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  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. /// with the flag off: `http` scheme (any ASCII case), loopback-literal host,
  30. /// no userinfo, query, or fragment (mirrors `parse_uri` + the §7.3 arm, not
  31. /// its code). `localhost` is excluded because the flag is off here.
  32. fn is_loopback_shaped(requested: &str) -> bool {
  33. let Some((scheme, rest)) = requested.split_once(':') else {
  34. return false;
  35. };
  36. if !scheme.eq_ignore_ascii_case("http") || !rest.starts_with("//") {
  37. return false;
  38. }
  39. let authority = &rest[2..];
  40. let host = authority.split([':', '/']).next().unwrap_or("");
  41. if host != "127.0.0.1" && host != "[::1]" {
  42. return false;
  43. }
  44. !requested.contains(['?', '#', '@'])
  45. }
  46. proptest! {
  47. #[test]
  48. fn redirect_exact_self_match(uri in registered_uri()) {
  49. prop_assert_eq!(
  50. match_redirect_uri(std::slice::from_ref(&uri), &uri, false),
  51. RedirectDecision::Exact
  52. );
  53. }
  54. #[test]
  55. fn redirect_empty_set_always_rejects(requested in "\\PC*") {
  56. let empty: Vec<String> = vec![];
  57. prop_assert_eq!(
  58. match_redirect_uri(&empty, &requested, false),
  59. RedirectDecision::Reject
  60. );
  61. }
  62. #[test]
  63. fn redirect_exact_implies_membership(
  64. registered in proptest::collection::vec(registered_uri(), 0..4),
  65. requested in "\\PC*",
  66. ) {
  67. if match_redirect_uri(&registered, &requested, false) == RedirectDecision::Exact {
  68. prop_assert!(registered.iter().any(|candidate| candidate == &requested));
  69. }
  70. }
  71. #[test]
  72. fn redirect_loopback_implies_loopback_request_shape(
  73. registered in proptest::collection::vec(registered_uri(), 0..4),
  74. requested in "\\PC*",
  75. ) {
  76. if match_redirect_uri(&registered, &requested, false) == RedirectDecision::Loopback {
  77. prop_assert!(
  78. is_loopback_shaped(&requested),
  79. "loopback match needs a loopback request: {:?}",
  80. requested
  81. );
  82. }
  83. }
  84. #[test]
  85. fn redirect_exact_outranks_loopback(port in 1024..65535_u16, path in "[a-z]{1,8}") {
  86. let loopback_reg = "http://127.0.0.1/cb".to_owned();
  87. let exact = format!("https://client.example.test/{path}");
  88. let requested_loopback = format!("http://127.0.0.1:{port}/cb");
  89. // The exact registration wins when it matches byte-for-byte, even with
  90. // a loopback candidate that would also match its own request shape.
  91. prop_assert_eq!(
  92. match_redirect_uri(&[loopback_reg.clone(), exact.clone()], &exact, false),
  93. RedirectDecision::Exact
  94. );
  95. // And the loopback arm still fires for the port-flexible request.
  96. prop_assert_eq!(
  97. match_redirect_uri(&[loopback_reg, exact], &requested_loopback, false),
  98. RedirectDecision::Loopback
  99. );
  100. }
  101. #[test]
  102. fn redirect_localhost_loopback_requires_the_flag(
  103. reg_port in 1024..65535_u16,
  104. req_port in 1024..65535_u16,
  105. path in "[a-z]{1,8}",
  106. ) {
  107. let registered = vec![format!("http://localhost:{reg_port}/{path}")];
  108. let requested = format!("http://localhost:{req_port}/{path}");
  109. // Flag off: `localhost` is never loopback, even for an identical request.
  110. prop_assert_eq!(
  111. match_redirect_uri(&registered, &requested, false),
  112. RedirectDecision::Reject
  113. );
  114. // Flag on: same host+path matches with the port ignored; the same port is
  115. // still an exact byte-for-byte match.
  116. let expected = if reg_port == req_port {
  117. RedirectDecision::Exact
  118. } else {
  119. RedirectDecision::Loopback
  120. };
  121. prop_assert_eq!(match_redirect_uri(&registered, &requested, true), expected);
  122. }
  123. }