scope_engine.rs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. //! `test(scope)` — scope parsing, rendering, and effective-scope enforcement.
  2. //!
  3. //! Pure unit coverage for `protocol::scope` (no database): the suite pins the
  4. //! RFC 6749 §3.3 grammar, the catalog/client/user/exchange subset terms, the
  5. //! omitted-scope default, the count/length caps, refresh narrowing, and a
  6. //! `proptest` parse∘render idempotence check. Test bodies use `Result` + `?`
  7. //! (`unwrap`/`expect`/`panic` are denied workspace-wide).
  8. //!
  9. //! The `enabled` / `user_entitled` sets stand in for the task-11 repository
  10. //! reads: at issuance the caller loads them fresh from Postgres via
  11. //! `db::scope_repo` / `db::user_scope_repo`, so CLI catalog changes apply
  12. //! immediately.
  13. use d_oauth::{
  14. config::GrantKind,
  15. error::{AppError, OAuthErrorCode},
  16. protocol::scope::{
  17. MAX_SCOPE_COUNT, MAX_SCOPE_NAME_LEN, MAX_SCOPE_TOTAL_LEN, ScopeInput, ScopeSet,
  18. effective_scope, narrow_on_refresh,
  19. },
  20. };
  21. use proptest::prelude::*;
  22. /// Test-body error.
  23. type TestResult = Result<(), AppError>;
  24. fn names(words: &[&str]) -> ScopeSet {
  25. match ScopeSet::parse(&words.join(" ")) {
  26. Ok(set) => set,
  27. Err(_) => ScopeSet::empty(),
  28. }
  29. }
  30. fn is_invalid_scope<T>(result: Result<T, AppError>) -> bool {
  31. matches!(
  32. result,
  33. Err(AppError::OAuth {
  34. code: OAuthErrorCode::InvalidScope,
  35. ..
  36. })
  37. )
  38. }
  39. #[test]
  40. fn scope_parse_renders_canonical_and_rejects_malformed_or_capped() -> TestResult {
  41. assert_eq!(ScopeSet::parse("b a b")?.render(), "a b");
  42. assert_eq!(ScopeSet::parse("a b")?.render(), "a b");
  43. assert!(ScopeSet::parse("")?.is_empty());
  44. for raw in ["a b", " a", "a ", "a\tb", "a\"b", "a\\b", "café"] {
  45. assert!(
  46. is_invalid_scope(ScopeSet::parse(raw)),
  47. "must reject {raw:?}"
  48. );
  49. }
  50. let many: Vec<String> = (0..MAX_SCOPE_COUNT + 8).map(|i| format!("s{i}")).collect();
  51. assert!(is_invalid_scope(ScopeSet::from_names(&many)));
  52. assert!(is_invalid_scope(ScopeSet::parse(
  53. &"x".repeat(MAX_SCOPE_TOTAL_LEN + 1)
  54. )));
  55. assert!(is_invalid_scope(ScopeSet::parse(
  56. &"y".repeat(MAX_SCOPE_NAME_LEN + 1)
  57. )));
  58. Ok(())
  59. }
  60. #[test]
  61. fn scope_effective_enforces_subset_default_and_grant_terms() -> TestResult {
  62. let enabled = names(&["read", "write"]);
  63. let registered = names(&["read", "write"]);
  64. let entitled = names(&["read"]);
  65. let allow = names(&["read"]);
  66. let none = ScopeSet::empty();
  67. let ask =
  68. |raw, reg: &ScopeSet, user: &ScopeSet, en: &ScopeSet, grant: GrantKind, list: &ScopeSet| {
  69. effective_scope(&ScopeInput {
  70. requested: raw,
  71. client_registered: reg,
  72. user_entitled: user,
  73. enabled: en,
  74. grant,
  75. exchange_allowlist: list,
  76. })
  77. };
  78. let code = GrantKind::AuthorizationCode;
  79. assert_eq!(
  80. ask(Some("read"), &registered, &entitled, &enabled, code, &none)?.render(),
  81. "read"
  82. );
  83. for raw in ["read write", "admin", "write"] {
  84. let user = if raw == "write" { &none } else { &entitled };
  85. assert!(is_invalid_scope(ask(
  86. Some(raw),
  87. &registered,
  88. user,
  89. &enabled,
  90. code,
  91. &none
  92. )));
  93. }
  94. let open = names(&["openid"]);
  95. assert!(is_invalid_scope(ask(
  96. Some("openid"),
  97. &open,
  98. &open,
  99. &enabled,
  100. code,
  101. &none
  102. )));
  103. let enabled_open = names(&["openid", "read"]);
  104. assert_eq!(
  105. ask(Some("openid"), &open, &open, &enabled_open, code, &none)?.render(),
  106. "openid"
  107. );
  108. assert_eq!(
  109. ask(
  110. None,
  111. &registered,
  112. &entitled,
  113. &enabled,
  114. GrantKind::DeviceCode,
  115. &none
  116. )?
  117. .render(),
  118. "read"
  119. );
  120. let cc = GrantKind::ClientCredentials;
  121. let ex = GrantKind::TokenExchange;
  122. assert_eq!(
  123. ask(Some("read write"), &registered, &none, &enabled, cc, &none)?.render(),
  124. "read write"
  125. );
  126. assert_eq!(
  127. ask(Some("read"), &registered, &none, &enabled, ex, &allow)?.render(),
  128. "read"
  129. );
  130. assert!(is_invalid_scope(ask(
  131. Some("write"),
  132. &registered,
  133. &none,
  134. &enabled,
  135. ex,
  136. &allow
  137. )));
  138. assert!(is_invalid_scope(ask(
  139. Some("read"),
  140. &registered,
  141. &none,
  142. &enabled,
  143. ex,
  144. &none
  145. )));
  146. Ok(())
  147. }
  148. #[test]
  149. fn scope_refresh_narrows_only_and_rechecks_catalog() -> TestResult {
  150. let enabled = names(&["read", "write"]);
  151. let previous = names(&["read", "write"]);
  152. assert_eq!(
  153. narrow_on_refresh(&previous, None, &enabled)?.render(),
  154. "read write"
  155. );
  156. assert_eq!(
  157. narrow_on_refresh(&previous, Some("read"), &enabled)?.render(),
  158. "read"
  159. );
  160. assert!(is_invalid_scope(narrow_on_refresh(
  161. &names(&["read"]),
  162. Some("read write"),
  163. &enabled
  164. )));
  165. assert!(is_invalid_scope(narrow_on_refresh(
  166. &previous,
  167. Some("admin"),
  168. &enabled
  169. )));
  170. assert!(is_invalid_scope(narrow_on_refresh(
  171. &previous,
  172. None,
  173. &names(&["read"])
  174. )));
  175. Ok(())
  176. }
  177. proptest! {
  178. #[test]
  179. fn scope_parse_render_roundtrip_is_idempotent(words in proptest::collection::vec("[a-z0-9:_.-]{1,12}", 0..8)) {
  180. match ScopeSet::parse(&words.join(" ")) {
  181. Ok(set) => match ScopeSet::parse(&set.render()) {
  182. Ok(again) => {
  183. prop_assert_eq!(set.render(), again.render());
  184. prop_assert!(again.is_subset_of(&set));
  185. }
  186. Err(_) => prop_assert!(false, "rendered form must re-parse"),
  187. },
  188. Err(_) => prop_assert!(words.is_empty(), "valid words must parse"),
  189. }
  190. }
  191. }