this repo has no description
1use tranquil_pds::delegation::{intersect_scopes, scopes::validate_delegation_scopes};
2use tranquil_pds::oauth::scopes::{
3 AccountAction, IdentityAttr, ParsedScope, RepoAction, ScopePermissions, parse_scope,
4 parse_scope_string,
5};
6
7#[test]
8fn test_repo_star_defaults_to_all_actions() {
9 let scope = parse_scope("repo:*");
10 if let ParsedScope::Repo(repo) = scope {
11 assert!(repo.actions.contains(&RepoAction::Create));
12 assert!(repo.actions.contains(&RepoAction::Update));
13 assert!(repo.actions.contains(&RepoAction::Delete));
14 assert_eq!(repo.actions.len(), 3);
15 } else {
16 panic!("Expected Repo scope");
17 }
18}
19
20#[test]
21fn test_repo_collection_without_actions_defaults_to_all() {
22 let scope = parse_scope("repo:app.bsky.feed.post");
23 if let ParsedScope::Repo(repo) = scope {
24 assert!(repo.actions.contains(&RepoAction::Create));
25 assert!(repo.actions.contains(&RepoAction::Update));
26 assert!(repo.actions.contains(&RepoAction::Delete));
27 } else {
28 panic!("Expected Repo scope");
29 }
30}
31
32#[test]
33fn test_repo_empty_string_after_colon() {
34 let scope = parse_scope("repo:");
35 if let ParsedScope::Repo(repo) = scope {
36 assert!(repo.collection.is_none());
37 } else {
38 panic!("Expected Repo scope");
39 }
40}
41
42#[test]
43fn test_rpc_wildcard_aud_wildcard_forbidden() {
44 let scope = parse_scope("rpc:*?aud=*");
45 assert!(matches!(scope, ParsedScope::Unknown(_)));
46}
47
48#[test]
49fn test_rpc_no_lxm_aud_wildcard_forbidden() {
50 let scope = parse_scope("rpc?aud=*");
51 assert!(matches!(scope, ParsedScope::Unknown(_)));
52}
53
54#[test]
55fn test_rpc_specific_lxm_wildcard_aud_allowed() {
56 let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*");
57 assert!(matches!(scope, ParsedScope::Rpc(_)));
58}
59
60#[test]
61fn test_rpc_wildcard_lxm_specific_aud_allowed() {
62 let scope = parse_scope("rpc:*?aud=did:web:api.bsky.app");
63 assert!(matches!(scope, ParsedScope::Rpc(_)));
64}
65
66#[test]
67fn test_unknown_scope_preserved() {
68 let scope = parse_scope("completely:made:up:scope");
69 if let ParsedScope::Unknown(s) = scope {
70 assert_eq!(s, "completely:made:up:scope");
71 } else {
72 panic!("Expected Unknown scope");
73 }
74}
75
76#[test]
77fn test_unknown_scope_with_params_preserved() {
78 let scope = parse_scope("unknown:thing?param=value");
79 if let ParsedScope::Unknown(s) = scope {
80 assert_eq!(s, "unknown:thing?param=value");
81 } else {
82 panic!("Expected Unknown scope");
83 }
84}
85
86#[test]
87fn test_blob_empty_accept() {
88 let scope = parse_scope("blob");
89 if let ParsedScope::Blob(blob) = scope {
90 assert!(blob.accept.is_empty());
91 assert!(blob.matches_mime("anything/goes"));
92 } else {
93 panic!("Expected Blob scope");
94 }
95}
96
97#[test]
98fn test_blob_matches_wildcard() {
99 let scope = parse_scope("blob:*/*");
100 if let ParsedScope::Blob(blob) = scope {
101 assert!(blob.matches_mime("image/png"));
102 assert!(blob.matches_mime("video/mp4"));
103 assert!(blob.matches_mime("application/json"));
104 } else {
105 panic!("Expected Blob scope");
106 }
107}
108
109#[test]
110fn test_blob_type_prefix_matching() {
111 let scope = parse_scope("blob:image/*");
112 if let ParsedScope::Blob(blob) = scope {
113 assert!(blob.matches_mime("image/png"));
114 assert!(blob.matches_mime("image/jpeg"));
115 assert!(blob.matches_mime("image/gif"));
116 assert!(!blob.matches_mime("video/mp4"));
117 assert!(!blob.matches_mime("images/png"));
118 } else {
119 panic!("Expected Blob scope");
120 }
121}
122
123#[test]
124fn test_account_default_action_is_read() {
125 let scope = parse_scope("account:email");
126 if let ParsedScope::Account(a) = scope {
127 assert_eq!(a.action, AccountAction::Read);
128 } else {
129 panic!("Expected Account scope");
130 }
131}
132
133#[test]
134fn test_multiple_scopes_parsing() {
135 let scopes = parse_scope_string("atproto repo:* blob:*/* transition:generic");
136 assert_eq!(scopes.len(), 4);
137 assert!(matches!(scopes[0], ParsedScope::Atproto));
138}
139
140#[test]
141fn test_permissions_null_scope_defaults_atproto() {
142 let perms = ScopePermissions::from_scope_string(None);
143 assert!(perms.has_full_access());
144 assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
145 assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
146 assert!(perms.allows_repo(RepoAction::Delete, "any.collection"));
147}
148
149#[test]
150fn test_permissions_empty_string_defaults_atproto() {
151 let perms = ScopePermissions::from_scope_string(Some(""));
152 assert!(!perms.has_full_access());
153}
154
155#[test]
156fn test_permissions_whitespace_only() {
157 let perms = ScopePermissions::from_scope_string(Some(" "));
158 assert!(!perms.has_full_access());
159}
160
161#[test]
162fn test_permissions_repo_collection_wildcard_prefix() {
163 let perms = ScopePermissions::from_scope_string(Some("repo:app.bsky.*?action=create"));
164 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
165 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.actor.profile"));
166 assert!(!perms.allows_repo(RepoAction::Create, "com.atproto.repo.blob"));
167 assert!(!perms.allows_repo(RepoAction::Update, "app.bsky.feed.post"));
168}
169
170#[test]
171fn test_permissions_rpc_lxm_wildcard_prefix() {
172 let perms =
173 ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app"));
174 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
175 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
176 assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.actor.getProfile"));
177}
178
179#[test]
180fn test_delegation_intersect_params_behavior() {
181 let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
182
183 assert!(
184 result.is_empty() || result.contains("repo:*"),
185 "Delegation intersection with different action params: '{}'",
186 result
187 );
188}
189
190#[test]
191fn test_delegation_intersect_wildcard_vs_specific() {
192 let result = intersect_scopes("repo:app.bsky.feed.post?action=create", "repo:*");
193 assert!(result.contains("repo:"));
194}
195
196#[test]
197fn test_delegation_validate_known_prefixes() {
198 assert!(validate_delegation_scopes("atproto").is_ok());
199 assert!(validate_delegation_scopes("repo:*").is_ok());
200 assert!(validate_delegation_scopes("blob:*/*").is_ok());
201 assert!(validate_delegation_scopes("rpc:*").is_ok());
202 assert!(validate_delegation_scopes("account:email").is_ok());
203 assert!(validate_delegation_scopes("identity:handle").is_ok());
204 assert!(validate_delegation_scopes("transition:generic").is_ok());
205}
206
207#[test]
208fn test_delegation_validate_unknown_prefixes() {
209 assert!(validate_delegation_scopes("invalid:scope").is_err());
210 assert!(validate_delegation_scopes("custom:something").is_err());
211 assert!(validate_delegation_scopes("made:up").is_err());
212}
213
214#[test]
215fn test_delegation_validate_empty() {
216 assert!(validate_delegation_scopes("").is_ok());
217}
218
219#[test]
220fn test_delegation_validate_multiple() {
221 assert!(validate_delegation_scopes("atproto repo:* blob:*/*").is_ok());
222 assert!(validate_delegation_scopes("atproto invalid:scope").is_err());
223}
224
225#[test]
226fn test_delegation_intersect_empty_granted_returns_empty() {
227 assert_eq!(intersect_scopes("atproto", ""), "");
228 assert_eq!(intersect_scopes("repo:*", ""), "");
229}
230
231#[test]
232fn test_delegation_intersect_no_overlap() {
233 let result = intersect_scopes("repo:app.bsky.feed.post", "repo:com.atproto.something");
234 assert!(result.is_empty());
235}
236
237#[test]
238fn test_scope_with_multiple_params() {
239 let scope = parse_scope("repo:*?action=create&action=delete");
240 if let ParsedScope::Repo(repo) = scope {
241 assert!(repo.actions.contains(&RepoAction::Create));
242 assert!(repo.actions.contains(&RepoAction::Delete));
243 assert!(!repo.actions.contains(&RepoAction::Update));
244 } else {
245 panic!("Expected Repo scope");
246 }
247}
248
249#[test]
250fn test_scope_invalid_action_ignored() {
251 let scope = parse_scope("repo:*?action=invalid");
252 if let ParsedScope::Repo(repo) = scope {
253 assert!(repo.actions.contains(&RepoAction::Create));
254 assert!(repo.actions.contains(&RepoAction::Update));
255 assert!(repo.actions.contains(&RepoAction::Delete));
256 } else {
257 panic!("Expected Repo scope");
258 }
259}
260
261#[test]
262fn test_include_scope_parsing() {
263 let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app");
264 if let ParsedScope::Include(inc) = scope {
265 assert_eq!(inc.nsid, "app.bsky.authFullApp");
266 assert_eq!(inc.aud, Some("did:web:api.bsky.app".to_string()));
267 } else {
268 panic!("Expected Include scope");
269 }
270}
271
272#[test]
273fn test_include_scope_no_aud() {
274 let scope = parse_scope("include:com.example.authBasic");
275 if let ParsedScope::Include(inc) = scope {
276 assert_eq!(inc.nsid, "com.example.authBasic");
277 assert!(inc.aud.is_none());
278 } else {
279 panic!("Expected Include scope");
280 }
281}
282
283#[test]
284fn test_identity_wildcard_vs_specific() {
285 let wildcard = parse_scope("identity:*");
286 let specific = parse_scope("identity:handle");
287
288 assert!(matches!(wildcard, ParsedScope::Identity(i) if i.attr == IdentityAttr::Wildcard));
289 assert!(matches!(specific, ParsedScope::Identity(i) if i.attr == IdentityAttr::Handle));
290}
291
292#[test]
293fn test_identity_unknown_attr() {
294 let scope = parse_scope("identity:unknown");
295 assert!(matches!(scope, ParsedScope::Unknown(_)));
296}
297
298#[test]
299fn test_transition_scopes_exact_match() {
300 assert!(matches!(
301 parse_scope("transition:generic"),
302 ParsedScope::TransitionGeneric
303 ));
304 assert!(matches!(
305 parse_scope("transition:chat.bsky"),
306 ParsedScope::TransitionChat
307 ));
308 assert!(matches!(
309 parse_scope("transition:email"),
310 ParsedScope::TransitionEmail
311 ));
312 assert!(matches!(
313 parse_scope("transition:unknown"),
314 ParsedScope::Unknown(_)
315 ));
316}