Microservice to bring 2FA to self hosted PDSes
1/// Authentication rules that can be validated against session data
2#[derive(Debug, Clone, PartialEq)]
3pub enum AuthRules {
4 /// Handle must end with the specified suffix
5 HandleEndsWith(String),
6 /// Handle must end with any of the specified suffixes (OR logic)
7 HandleEndsWithAny(Vec<String>),
8 /// DID must exactly match the specified value
9 DidEquals(String),
10 /// DID must match any of the specified values (OR logic)
11 DidEqualsAny(Vec<String>),
12 /// Session must have the specified OAuth scope
13 ScopeEquals(String),
14 /// Session must have ANY of the specified scopes (OR logic)
15 ScopeEqualsAny(Vec<String>),
16 /// Session must have ALL of the specified scopes (AND logic)
17 ScopeEqualsAll(Vec<String>),
18 /// All nested rules must be satisfied (AND logic)
19 All(Vec<AuthRules>),
20 /// At least one nested rule must be satisfied (OR logic)
21 Any(Vec<AuthRules>),
22}
23
24/// Session data used for authentication validation
25#[derive(Debug, Clone)]
26pub struct SessionData {
27 /// The user's DID
28 pub did: String,
29 /// The user's handle
30 pub handle: String,
31 /// OAuth 2.0 scopes granted to this session
32 pub scopes: Vec<String>,
33}
34
35impl AuthRules {
36 /// Validates if the given session data meets the authentication requirements
37 pub fn validate(&self, session_data: &SessionData) -> bool {
38 match self {
39 AuthRules::HandleEndsWith(suffix) => session_data.handle.ends_with(suffix),
40 AuthRules::HandleEndsWithAny(suffixes) => {
41 suffixes.iter().any(|s| session_data.handle.ends_with(s))
42 }
43 AuthRules::DidEquals(did) => session_data.did == *did,
44 AuthRules::DidEqualsAny(dids) => dids.iter().any(|d| session_data.did == *d),
45 AuthRules::ScopeEquals(scope) => has_scope(&session_data.scopes, scope),
46 AuthRules::ScopeEqualsAny(scopes) => has_any_scope(&session_data.scopes, scopes),
47 AuthRules::ScopeEqualsAll(scopes) => has_all_scopes(&session_data.scopes, scopes),
48 AuthRules::All(rules) => rules.iter().all(|r| r.validate(session_data)),
49 AuthRules::Any(rules) => rules.iter().any(|r| r.validate(session_data)),
50 }
51 }
52}
53
54/// Checks if the session has a specific scope
55pub fn has_scope(scopes: &[String], required_scope: &str) -> bool {
56 scopes.iter().any(|s| s == required_scope)
57}
58
59/// Checks if the session has ANY of the required scopes (OR logic)
60pub fn has_any_scope(scopes: &[String], required_scopes: &[String]) -> bool {
61 required_scopes.iter().any(|req| has_scope(scopes, req))
62}
63
64/// Checks if the session has ALL of the required scopes (AND logic)
65pub fn has_all_scopes(scopes: &[String], required_scopes: &[String]) -> bool {
66 required_scopes.iter().all(|req| has_scope(scopes, req))
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 fn test_session(did: &str, handle: &str, scopes: Vec<&str>) -> SessionData {
74 SessionData {
75 did: did.to_string(),
76 handle: handle.to_string(),
77 scopes: scopes.into_iter().map(|s| s.to_string()).collect(),
78 }
79 }
80
81 #[test]
82 fn test_handle_ends_with() {
83 let rules = AuthRules::HandleEndsWith(".blacksky.team".into());
84
85 let valid = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]);
86 assert!(rules.validate(&valid));
87
88 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]);
89 assert!(!rules.validate(&invalid));
90 }
91
92 #[test]
93 fn test_handle_ends_with_any() {
94 let rules = AuthRules::HandleEndsWithAny(vec![".blacksky.team".into(), ".bsky.team".into()]);
95
96 let valid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]);
97 assert!(rules.validate(&valid1));
98
99 let valid2 = test_session("did:plc:123", "bob.bsky.team", vec!["atproto"]);
100 assert!(rules.validate(&valid2));
101
102 let invalid = test_session("did:plc:123", "charlie.bsky.social", vec!["atproto"]);
103 assert!(!rules.validate(&invalid));
104 }
105
106 #[test]
107 fn test_did_equals() {
108 let rules = AuthRules::DidEquals("did:plc:alice".into());
109
110 let valid = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]);
111 assert!(rules.validate(&valid));
112
113 let invalid = test_session("did:plc:bob", "bob.bsky.social", vec!["atproto"]);
114 assert!(!rules.validate(&invalid));
115 }
116
117 #[test]
118 fn test_any_combinator() {
119 let rules = AuthRules::Any(vec![
120 AuthRules::DidEquals("did:plc:admin".into()),
121 AuthRules::HandleEndsWith(".blacksky.team".into()),
122 ]);
123
124 // First condition met
125 let valid1 = test_session("did:plc:admin", "admin.bsky.social", vec!["atproto"]);
126 assert!(rules.validate(&valid1));
127
128 // Second condition met
129 let valid2 = test_session("did:plc:user", "user.blacksky.team", vec!["atproto"]);
130 assert!(rules.validate(&valid2));
131
132 // Neither condition met
133 let invalid = test_session("did:plc:user", "user.bsky.social", vec!["atproto"]);
134 assert!(!rules.validate(&invalid));
135 }
136
137 #[test]
138 fn test_all_combinator() {
139 let rules = AuthRules::All(vec![
140 AuthRules::HandleEndsWith(".blacksky.team".into()),
141 AuthRules::DidEqualsAny(vec!["did:plc:alice".into(), "did:plc:bob".into()]),
142 ]);
143
144 // Both conditions met
145 let valid = test_session("did:plc:alice", "alice.blacksky.team", vec!["atproto"]);
146 assert!(rules.validate(&valid));
147
148 // Handle wrong
149 let invalid1 = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]);
150 assert!(!rules.validate(&invalid1));
151
152 // DID wrong
153 let invalid2 = test_session("did:plc:charlie", "charlie.blacksky.team", vec!["atproto"]);
154 assert!(!rules.validate(&invalid2));
155 }
156
157 // ========================================================================
158 // Scope Tests
159 // ========================================================================
160
161 #[test]
162 fn test_scope_equals() {
163 let rules = AuthRules::ScopeEquals("transition:generic".into());
164
165 let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]);
166 assert!(rules.validate(&valid));
167
168 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]);
169 assert!(!rules.validate(&invalid));
170 }
171
172 #[test]
173 fn test_scope_any() {
174 let rules = AuthRules::ScopeEqualsAny(vec![
175 "transition:generic".into(),
176 "repo:app.bsky.feed.post".into(),
177 ]);
178
179 // Has first scope
180 let valid1 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]);
181 assert!(rules.validate(&valid1));
182
183 // Has second scope
184 let valid2 = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "repo:app.bsky.feed.post"]);
185 assert!(rules.validate(&valid2));
186
187 // Has neither
188 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]);
189 assert!(!rules.validate(&invalid));
190 }
191
192 #[test]
193 fn test_scope_all() {
194 let rules = AuthRules::ScopeEqualsAll(vec![
195 "atproto".into(),
196 "transition:generic".into(),
197 ]);
198
199 // Has both scopes
200 let valid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto", "transition:generic"]);
201 assert!(rules.validate(&valid));
202
203 // Missing one scope
204 let invalid = test_session("did:plc:123", "alice.bsky.social", vec!["atproto"]);
205 assert!(!rules.validate(&invalid));
206 }
207
208 // ========================================================================
209 // Combined Rules Tests (Identity + Scope)
210 // ========================================================================
211
212 #[test]
213 fn test_handle_ends_with_and_scope() {
214 let rules = AuthRules::All(vec![
215 AuthRules::HandleEndsWith(".blacksky.team".into()),
216 AuthRules::ScopeEquals("transition:generic".into()),
217 ]);
218
219 // Both conditions met
220 let valid = test_session(
221 "did:plc:123",
222 "alice.blacksky.team",
223 vec!["atproto", "transition:generic"],
224 );
225 assert!(rules.validate(&valid));
226
227 // Handle correct, scope wrong
228 let invalid1 = test_session("did:plc:123", "alice.blacksky.team", vec!["atproto"]);
229 assert!(!rules.validate(&invalid1));
230
231 // Scope correct, handle wrong
232 let invalid2 = test_session(
233 "did:plc:123",
234 "alice.bsky.social",
235 vec!["atproto", "transition:generic"],
236 );
237 assert!(!rules.validate(&invalid2));
238 }
239
240 #[test]
241 fn test_did_with_scope() {
242 let rules = AuthRules::All(vec![
243 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
244 AuthRules::ScopeEquals("transition:generic".into()),
245 ]);
246
247 // Both conditions met
248 let valid = test_session(
249 "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
250 "admin.bsky.social",
251 vec!["atproto", "transition:generic"],
252 );
253 assert!(rules.validate(&valid));
254
255 // DID correct, scope wrong
256 let invalid1 = test_session(
257 "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
258 "admin.bsky.social",
259 vec!["atproto"],
260 );
261 assert!(!rules.validate(&invalid1));
262
263 // Scope correct, DID wrong
264 let invalid2 = test_session(
265 "did:plc:wrongdid",
266 "admin.bsky.social",
267 vec!["atproto", "transition:generic"],
268 );
269 assert!(!rules.validate(&invalid2));
270 }
271
272 #[test]
273 fn test_complex_admin_or_moderator_rule() {
274 // Admin DID OR (moderator handle + scope)
275 let rules = AuthRules::Any(vec![
276 AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
277 AuthRules::All(vec![
278 AuthRules::HandleEndsWith(".mod.team".into()),
279 AuthRules::ScopeEquals("account:email".into()),
280 ]),
281 ]);
282
283 // Admin DID (doesn't need scope)
284 let admin = test_session("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "admin.bsky.social", vec!["atproto"]);
285 assert!(rules.validate(&admin));
286
287 // Moderator with correct handle and scope
288 let mod_valid = test_session(
289 "did:plc:somemod",
290 "alice.mod.team",
291 vec!["atproto", "account:email"],
292 );
293 assert!(rules.validate(&mod_valid));
294
295 // Moderator handle but missing scope
296 let mod_no_scope = test_session("did:plc:somemod", "alice.mod.team", vec!["atproto"]);
297 assert!(!rules.validate(&mod_no_scope));
298
299 // Has scope but not moderator handle
300 let not_mod = test_session(
301 "did:plc:someuser",
302 "alice.bsky.social",
303 vec!["atproto", "account:email"],
304 );
305 assert!(!rules.validate(¬_mod));
306 }
307
308 // ========================================================================
309 // Additional Coverage Tests
310 // ========================================================================
311
312 #[test]
313 fn test_did_equals_any() {
314 let rules = AuthRules::DidEqualsAny(vec![
315 "did:plc:alice123456789012345678".into(),
316 "did:plc:bob12345678901234567890".into(),
317 "did:plc:charlie12345678901234567".into(),
318 ]);
319
320 // First DID matches
321 let valid1 = test_session("did:plc:alice123456789012345678", "alice.bsky.social", vec!["atproto"]);
322 assert!(rules.validate(&valid1));
323
324 // Second DID matches
325 let valid2 = test_session("did:plc:bob12345678901234567890", "bob.bsky.social", vec!["atproto"]);
326 assert!(rules.validate(&valid2));
327
328 // Third DID matches
329 let valid3 = test_session("did:plc:charlie12345678901234567", "charlie.bsky.social", vec!["atproto"]);
330 assert!(rules.validate(&valid3));
331
332 // DID not in list
333 let invalid = test_session("did:plc:unknown1234567890123456", "unknown.bsky.social", vec!["atproto"]);
334 assert!(!rules.validate(&invalid));
335
336 // Partial match should fail (prefix)
337 let partial = test_session("did:plc:alice", "alice.bsky.social", vec!["atproto"]);
338 assert!(!rules.validate(&partial));
339 }
340
341 // ========================================================================
342 // Empty Combinator Edge Cases
343 // ========================================================================
344
345 #[test]
346 fn test_empty_any_returns_false() {
347 // Any with empty rules should return false (no rule can be satisfied)
348 let rules = AuthRules::Any(vec![]);
349 let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]);
350 assert!(!rules.validate(&session));
351 }
352
353 #[test]
354 fn test_empty_all_returns_true() {
355 // All with empty rules should return true (vacuous truth - all zero rules are satisfied)
356 let rules = AuthRules::All(vec![]);
357 let session = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]);
358 assert!(rules.validate(&session));
359 }
360
361 // ========================================================================
362 // Handle Edge Cases
363 // ========================================================================
364
365 #[test]
366 fn test_handle_exact_suffix_match() {
367 // Handle that IS exactly the suffix should still match
368 let rules = AuthRules::HandleEndsWith(".blacksky.team".into());
369
370 // Handle is exactly the suffix
371 let exact = test_session("did:plc:test12345678901234567", ".blacksky.team", vec!["atproto"]);
372 assert!(rules.validate(&exact));
373
374 // Normal case - handle with prefix
375 let normal = test_session("did:plc:test12345678901234567", "alice.blacksky.team", vec!["atproto"]);
376 assert!(rules.validate(&normal));
377
378 // Empty handle should not match
379 let empty = test_session("did:plc:test12345678901234567", "", vec!["atproto"]);
380 assert!(!rules.validate(&empty));
381 }
382
383 // ========================================================================
384 // Deeply Nested Combinators
385 // ========================================================================
386
387 #[test]
388 fn test_deeply_nested_rules() {
389 // Complex nested rule: Any(All(Any(...), ...), All(...))
390 // Scenario: (Admin OR VIP) AND (has scope OR team member)
391 // OR
392 // Specific moderator DID
393 let rules = AuthRules::Any(vec![
394 // Branch 1: Complex nested condition
395 AuthRules::All(vec![
396 // Must be admin or VIP
397 AuthRules::Any(vec![
398 AuthRules::DidEquals("did:plc:admin123456789012345".into()),
399 AuthRules::HandleEndsWith(".vip.social".into()),
400 ]),
401 // AND must have scope or be team member
402 AuthRules::Any(vec![
403 AuthRules::ScopeEquals("transition:generic".into()),
404 AuthRules::HandleEndsWith(".team.internal".into()),
405 ]),
406 ]),
407 // Branch 2: Specific moderator bypass
408 AuthRules::DidEquals("did:plc:moderator12345678901".into()),
409 ]);
410
411 // Admin with required scope - should pass via Branch 1
412 let admin_with_scope = test_session(
413 "did:plc:admin123456789012345",
414 "admin.bsky.social",
415 vec!["atproto", "transition:generic"],
416 );
417 assert!(rules.validate(&admin_with_scope));
418
419 // VIP with required scope - should pass via Branch 1
420 let vip_with_scope = test_session(
421 "did:plc:somevip1234567890123",
422 "alice.vip.social",
423 vec!["atproto", "transition:generic"],
424 );
425 assert!(rules.validate(&vip_with_scope));
426
427 // Moderator bypass - should pass via Branch 2
428 let moderator = test_session(
429 "did:plc:moderator12345678901",
430 "mod.bsky.social",
431 vec!["atproto"],
432 );
433 assert!(rules.validate(&moderator));
434
435 // Admin without scope and not team member - should fail
436 let admin_no_scope = test_session(
437 "did:plc:admin123456789012345",
438 "admin.bsky.social",
439 vec!["atproto"],
440 );
441 assert!(!rules.validate(&admin_no_scope));
442
443 // Random user - should fail
444 let random = test_session(
445 "did:plc:random12345678901234",
446 "random.bsky.social",
447 vec!["atproto", "transition:generic"],
448 );
449 assert!(!rules.validate(&random));
450 }
451
452 // ========================================================================
453 // ATProto Scope Specification Tests
454 // ========================================================================
455
456 #[test]
457 fn test_scope_with_query_params() {
458 // ATProto scopes can have query parameters
459 // These are treated as literal strings (exact matching)
460
461 // blob scope with accept parameter
462 let blob_rules = AuthRules::ScopeEquals("blob?accept=image/*".into());
463 let has_blob = test_session(
464 "did:plc:test12345678901234567",
465 "test.bsky.social",
466 vec!["atproto", "blob?accept=image/*"],
467 );
468 assert!(blob_rules.validate(&has_blob));
469
470 // Different query param should not match
471 let wrong_blob = test_session(
472 "did:plc:test12345678901234567",
473 "test.bsky.social",
474 vec!["atproto", "blob?accept=video/*"],
475 );
476 assert!(!blob_rules.validate(&wrong_blob));
477
478 // account:repo with action parameter
479 let account_rules = AuthRules::ScopeEquals("account:repo?action=manage".into());
480 let has_account = test_session(
481 "did:plc:test12345678901234567",
482 "test.bsky.social",
483 vec!["atproto", "account:repo?action=manage"],
484 );
485 assert!(account_rules.validate(&has_account));
486
487 // Multiple query params
488 let multi_param_rules = AuthRules::ScopeEquals("blob?accept=image/*&accept=video/*".into());
489 let has_multi = test_session(
490 "did:plc:test12345678901234567",
491 "test.bsky.social",
492 vec!["atproto", "blob?accept=image/*&accept=video/*"],
493 );
494 assert!(multi_param_rules.validate(&has_multi));
495 }
496
497 #[test]
498 fn test_scope_exact_matching_no_wildcards() {
499 // ATProto spec: Wildcards do NOT work for collections
500 // repo:app.bsky.feed.post should NOT match repo:app.bsky.feed.*
501 // This test verifies our exact matching is correct
502
503 let rules = AuthRules::ScopeEquals("repo:app.bsky.feed.post".into());
504
505 // Exact match works
506 let exact = test_session(
507 "did:plc:test12345678901234567",
508 "test.bsky.social",
509 vec!["atproto", "repo:app.bsky.feed.post"],
510 );
511 assert!(rules.validate(&exact));
512
513 // Wildcard scope in JWT should NOT satisfy specific requirement
514 // (user has repo:app.bsky.feed.* but endpoint requires repo:app.bsky.feed.post)
515 let wildcard = test_session(
516 "did:plc:test12345678901234567",
517 "test.bsky.social",
518 vec!["atproto", "repo:app.bsky.feed.*"],
519 );
520 assert!(!rules.validate(&wildcard));
521
522 // Different collection should not match
523 let different = test_session(
524 "did:plc:test12345678901234567",
525 "test.bsky.social",
526 vec!["atproto", "repo:app.bsky.feed.like"],
527 );
528 assert!(!rules.validate(&different));
529
530 // Prefix should not match
531 let prefix = test_session(
532 "did:plc:test12345678901234567",
533 "test.bsky.social",
534 vec!["atproto", "repo:app.bsky.feed"],
535 );
536 assert!(!rules.validate(&prefix));
537
538 // repo:* should not match specific collection (exact matching)
539 let full_wildcard = test_session(
540 "did:plc:test12345678901234567",
541 "test.bsky.social",
542 vec!["atproto", "repo:*"],
543 );
544 assert!(!rules.validate(&full_wildcard));
545 }
546
547 #[test]
548 fn test_scope_case_sensitivity() {
549 // OAuth scopes are case-sensitive per RFC 6749
550
551 let rules = AuthRules::ScopeEquals("atproto".into());
552
553 // Exact case matches
554 let exact = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["atproto"]);
555 assert!(rules.validate(&exact));
556
557 // UPPERCASE should NOT match
558 let upper = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["ATPROTO"]);
559 assert!(!rules.validate(&upper));
560
561 // Mixed case should NOT match
562 let mixed = test_session("did:plc:test12345678901234567", "test.bsky.social", vec!["AtProto"]);
563 assert!(!rules.validate(&mixed));
564
565 // Test with namespaced scope
566 let ns_rules = AuthRules::ScopeEquals("transition:generic".into());
567 let ns_upper = test_session(
568 "did:plc:test12345678901234567",
569 "test.bsky.social",
570 vec!["TRANSITION:GENERIC"],
571 );
572 assert!(!ns_rules.validate(&ns_upper));
573 }
574
575 #[test]
576 fn test_scope_with_wildcards_exact_match() {
577 // Wildcard scopes like blob:*/* and identity:* are stored as literal strings
578 // The middleware does exact matching, so JWT must contain the exact string
579
580 // blob:*/* - full blob wildcard
581 let blob_rules = AuthRules::ScopeEquals("blob:*/*".into());
582 let has_blob_wildcard = test_session(
583 "did:plc:test12345678901234567",
584 "test.bsky.social",
585 vec!["atproto", "blob:*/*"],
586 );
587 assert!(blob_rules.validate(&has_blob_wildcard));
588
589 // Specific blob type should NOT match blob:*/* requirement
590 let has_specific_blob = test_session(
591 "did:plc:test12345678901234567",
592 "test.bsky.social",
593 vec!["atproto", "blob:image/png"],
594 );
595 assert!(!blob_rules.validate(&has_specific_blob));
596
597 // identity:* - full identity wildcard
598 let identity_rules = AuthRules::ScopeEquals("identity:*".into());
599 let has_identity_wildcard = test_session(
600 "did:plc:test12345678901234567",
601 "test.bsky.social",
602 vec!["atproto", "identity:*"],
603 );
604 assert!(identity_rules.validate(&has_identity_wildcard));
605
606 // Specific identity scope should NOT match identity:* requirement
607 let has_specific_identity = test_session(
608 "did:plc:test12345678901234567",
609 "test.bsky.social",
610 vec!["atproto", "identity:handle"],
611 );
612 assert!(!identity_rules.validate(&has_specific_identity));
613 }
614
615 // ========================================================================
616 // Scope Helper Function Tests
617 // ========================================================================
618
619 #[test]
620 fn test_has_scope_helper() {
621 let scopes: Vec<String> = vec![
622 "atproto".to_string(),
623 "transition:generic".to_string(),
624 "repo:app.bsky.feed.post".to_string(),
625 ];
626
627 // Present scopes
628 assert!(has_scope(&scopes, "atproto"));
629 assert!(has_scope(&scopes, "transition:generic"));
630 assert!(has_scope(&scopes, "repo:app.bsky.feed.post"));
631
632 // Absent scopes
633 assert!(!has_scope(&scopes, "identity:*"));
634 assert!(!has_scope(&scopes, ""));
635 assert!(!has_scope(&scopes, "ATPROTO")); // Case sensitive
636
637 // Empty scopes list
638 let empty: Vec<String> = vec![];
639 assert!(!has_scope(&empty, "atproto"));
640 }
641
642 #[test]
643 fn test_has_any_scope_helper() {
644 let scopes: Vec<String> = vec![
645 "atproto".to_string(),
646 "repo:app.bsky.feed.post".to_string(),
647 ];
648
649 // Has one of the required scopes
650 let required1 = vec!["transition:generic".to_string(), "atproto".to_string()];
651 assert!(has_any_scope(&scopes, &required1));
652
653 // Has none of the required scopes
654 let required2 = vec!["transition:generic".to_string(), "identity:*".to_string()];
655 assert!(!has_any_scope(&scopes, &required2));
656
657 // Empty required list - should return false (no scope to match)
658 let empty_required: Vec<String> = vec![];
659 assert!(!has_any_scope(&scopes, &empty_required));
660
661 // Empty scopes list
662 let empty_scopes: Vec<String> = vec![];
663 assert!(!has_any_scope(&empty_scopes, &required1));
664 }
665
666 #[test]
667 fn test_has_all_scopes_helper() {
668 let scopes: Vec<String> = vec![
669 "atproto".to_string(),
670 "transition:generic".to_string(),
671 "repo:app.bsky.feed.post".to_string(),
672 ];
673
674 // Has all required scopes
675 let required1 = vec!["atproto".to_string(), "transition:generic".to_string()];
676 assert!(has_all_scopes(&scopes, &required1));
677
678 // Missing one required scope
679 let required2 = vec!["atproto".to_string(), "identity:*".to_string()];
680 assert!(!has_all_scopes(&scopes, &required2));
681
682 // Empty required list - should return true (vacuously all zero required are present)
683 let empty_required: Vec<String> = vec![];
684 assert!(has_all_scopes(&scopes, &empty_required));
685
686 // Empty scopes list with requirements
687 let empty_scopes: Vec<String> = vec![];
688 assert!(!has_all_scopes(&empty_scopes, &required1));
689
690 // Single scope requirement
691 let single = vec!["atproto".to_string()];
692 assert!(has_all_scopes(&scopes, &single));
693 }
694}