scope_engine.rs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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, &enabled, &enabled)?.render(),
  154. "read write"
  155. );
  156. assert_eq!(
  157. narrow_on_refresh(&previous, Some("read"), &enabled, &enabled, &enabled)?.render(),
  158. "read"
  159. );
  160. assert!(is_invalid_scope(narrow_on_refresh(
  161. &names(&["read"]),
  162. Some("read write"),
  163. &enabled,
  164. &enabled,
  165. &enabled
  166. )));
  167. assert!(is_invalid_scope(narrow_on_refresh(
  168. &previous,
  169. Some("admin"),
  170. &enabled,
  171. &enabled,
  172. &enabled
  173. )));
  174. assert!(is_invalid_scope(narrow_on_refresh(
  175. &previous,
  176. None,
  177. &names(&["read"]),
  178. &enabled,
  179. &enabled
  180. )));
  181. Ok(())
  182. }
  183. #[test]
  184. fn scope_refresh_enforces_current_entitlements() -> TestResult {
  185. let enabled = names(&["read", "write"]);
  186. let previous = names(&["read", "write"]);
  187. let entitled_both = names(&["read", "write"]);
  188. let entitled_read = names(&["read"]);
  189. let registered_both = names(&["read", "write"]);
  190. let registered_read = names(&["read"]);
  191. // Omitted scope silently narrows to the still-entitled, still-registered
  192. // intersection; a fully de-entitled grant narrows to empty.
  193. assert_eq!(
  194. narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_read)?.render(),
  195. "read"
  196. );
  197. assert_eq!(
  198. narrow_on_refresh(&previous, None, &enabled, &registered_read, &entitled_both)?.render(),
  199. "read"
  200. );
  201. assert_eq!(
  202. narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_both)?.render(),
  203. "read write"
  204. );
  205. assert!(
  206. narrow_on_refresh(
  207. &previous,
  208. None,
  209. &enabled,
  210. &registered_both,
  211. &ScopeSet::empty()
  212. )?
  213. .is_empty()
  214. );
  215. // An explicit request for a no-longer-entitled or de-registered scope is
  216. // `invalid_scope`; a still-entitled explicit narrow is kept.
  217. assert!(is_invalid_scope(narrow_on_refresh(
  218. &previous,
  219. Some("read write"),
  220. &enabled,
  221. &registered_both,
  222. &entitled_read
  223. )));
  224. assert!(is_invalid_scope(narrow_on_refresh(
  225. &previous,
  226. Some("read write"),
  227. &enabled,
  228. &registered_read,
  229. &entitled_both
  230. )));
  231. assert_eq!(
  232. narrow_on_refresh(
  233. &previous,
  234. Some("read"),
  235. &enabled,
  236. &registered_both,
  237. &entitled_both
  238. )?
  239. .render(),
  240. "read"
  241. );
  242. Ok(())
  243. }
  244. proptest! {
  245. #[test]
  246. fn scope_parse_render_roundtrip_is_idempotent(words in proptest::collection::vec("[a-z0-9:_.-]{1,12}", 0..8)) {
  247. match ScopeSet::parse(&words.join(" ")) {
  248. Ok(set) => match ScopeSet::parse(&set.render()) {
  249. Ok(again) => {
  250. prop_assert_eq!(set.render(), again.render());
  251. prop_assert!(again.is_subset_of(&set));
  252. }
  253. Err(_) => prop_assert!(false, "rendered form must re-parse"),
  254. },
  255. Err(_) => prop_assert!(words.is_empty(), "valid words must parse"),
  256. }
  257. }
  258. }