Microservice to bring 2FA to self hosted PDSes
91
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 694 lines 26 kB view raw
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(&not_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}