scope_engine.rs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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, refresh_scopes,
  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_eq!(
  175. narrow_on_refresh(&previous, None, &names(&["read"]), &enabled, &enabled)?.render(),
  176. "read"
  177. );
  178. Ok(())
  179. }
  180. #[test]
  181. fn refresh_scopes_retained_vs_access() -> TestResult {
  182. let full = names(&["read", "write"]);
  183. let read = names(&["read"]);
  184. // Explicit narrow: retained keeps the re-checked previous intersection,
  185. // access narrows to the candidate.
  186. let split = refresh_scopes(
  187. &names(&["read", "write"]),
  188. Some("read"),
  189. &full,
  190. &full,
  191. &full,
  192. )?;
  193. assert_eq!(split.retained.render(), "read write");
  194. assert_eq!(split.access.render(), "read");
  195. // Omitted scope: retained and access agree.
  196. let kept = refresh_scopes(&names(&["read", "write"]), None, &full, &full, &full)?;
  197. assert_eq!(kept.retained.render(), "read write");
  198. assert_eq!(kept.access.render(), "read write");
  199. assert_eq!(kept.retained, kept.access);
  200. // Revoked entries drop silently from retained (catalog, registration, and
  201. // entitlement terms each narrow); an explicit narrow of the survivor still
  202. // applies only to access.
  203. let catalog_revoked = refresh_scopes(&names(&["read", "write"]), None, &read, &full, &full)?;
  204. assert_eq!(catalog_revoked.retained.render(), "read");
  205. assert_eq!(catalog_revoked.access.render(), "read");
  206. let entitled_revoked = refresh_scopes(
  207. &names(&["read", "write"]),
  208. Some("read"),
  209. &full,
  210. &full,
  211. &read,
  212. )?;
  213. assert_eq!(entitled_revoked.retained.render(), "read");
  214. assert_eq!(entitled_revoked.access.render(), "read");
  215. // A fully revoked grant retains (and grants) nothing rather than failing.
  216. let emptied = refresh_scopes(
  217. &names(&["read", "write"]),
  218. None,
  219. &full,
  220. &full,
  221. &ScopeSet::empty(),
  222. )?;
  223. assert!(emptied.retained.is_empty());
  224. assert!(emptied.access.is_empty());
  225. // Widening via an explicit request is still `invalid_scope`.
  226. assert!(is_invalid_scope(refresh_scopes(
  227. &read,
  228. Some("read write"),
  229. &full,
  230. &full,
  231. &full
  232. )));
  233. Ok(())
  234. }
  235. #[test]
  236. fn scope_refresh_enforces_current_entitlements() -> TestResult {
  237. let enabled = names(&["read", "write"]);
  238. let previous = names(&["read", "write"]);
  239. let entitled_both = names(&["read", "write"]);
  240. let entitled_read = names(&["read"]);
  241. let registered_both = names(&["read", "write"]);
  242. let registered_read = names(&["read"]);
  243. // Omitted scope silently narrows to the still-entitled, still-registered
  244. // intersection; a fully de-entitled grant narrows to empty.
  245. assert_eq!(
  246. narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_read)?.render(),
  247. "read"
  248. );
  249. assert_eq!(
  250. narrow_on_refresh(&previous, None, &enabled, &registered_read, &entitled_both)?.render(),
  251. "read"
  252. );
  253. assert_eq!(
  254. narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_both)?.render(),
  255. "read write"
  256. );
  257. assert!(
  258. narrow_on_refresh(
  259. &previous,
  260. None,
  261. &enabled,
  262. &registered_both,
  263. &ScopeSet::empty()
  264. )?
  265. .is_empty()
  266. );
  267. // An explicit request for a no-longer-entitled or de-registered scope is
  268. // `invalid_scope`; a still-entitled explicit narrow is kept.
  269. assert!(is_invalid_scope(narrow_on_refresh(
  270. &previous,
  271. Some("read write"),
  272. &enabled,
  273. &registered_both,
  274. &entitled_read
  275. )));
  276. assert!(is_invalid_scope(narrow_on_refresh(
  277. &previous,
  278. Some("read write"),
  279. &enabled,
  280. &registered_read,
  281. &entitled_both
  282. )));
  283. assert_eq!(
  284. narrow_on_refresh(
  285. &previous,
  286. Some("read"),
  287. &enabled,
  288. &registered_both,
  289. &entitled_both
  290. )?
  291. .render(),
  292. "read"
  293. );
  294. Ok(())
  295. }
  296. proptest! {
  297. #[test]
  298. fn scope_parse_render_roundtrip_is_idempotent(words in proptest::collection::vec("[a-z0-9:_.-]{1,12}", 0..8)) {
  299. match ScopeSet::parse(&words.join(" ")) {
  300. Ok(set) => match ScopeSet::parse(&set.render()) {
  301. Ok(again) => {
  302. prop_assert_eq!(set.render(), again.render());
  303. prop_assert!(again.is_subset_of(&set));
  304. }
  305. Err(_) => prop_assert!(false, "rendered form must re-parse"),
  306. },
  307. Err(_) => prop_assert!(words.is_empty(), "valid words must parse"),
  308. }
  309. }
  310. }