guts_auth/
store.rs

1//! In-memory authorization store.
2
3use crate::{
4    branch_protection::BranchProtection,
5    collaborator::Collaborator,
6    error::{AuthError, Result},
7    organization::{OrgMember, OrgRole, Organization},
8    permission::Permission,
9    team::Team,
10    webhook::Webhook,
11};
12use parking_lot::RwLock;
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16/// Thread-safe in-memory store for authorization data.
17#[derive(Debug, Default)]
18pub struct AuthStore {
19    /// Next available ID for new entities.
20    next_id: AtomicU64,
21
22    /// Organizations by ID.
23    organizations: RwLock<HashMap<u64, Organization>>,
24
25    /// Organization name to ID mapping.
26    org_name_index: RwLock<HashMap<String, u64>>,
27
28    /// Teams by ID.
29    teams: RwLock<HashMap<u64, Team>>,
30
31    /// Collaborators by (repo_key, user) pair.
32    collaborators: RwLock<HashMap<(String, String), Collaborator>>,
33
34    /// Collaborator repo index: repo_key -> list of (user, permission).
35    collaborator_index: RwLock<HashMap<String, Vec<(String, Permission)>>>,
36
37    /// Branch protection rules by (repo_key, pattern) pair.
38    branch_protections: RwLock<HashMap<(String, String), BranchProtection>>,
39
40    /// Webhooks by ID.
41    webhooks: RwLock<HashMap<u64, Webhook>>,
42
43    /// Webhook repo index: repo_key -> webhook IDs.
44    webhook_index: RwLock<HashMap<String, Vec<u64>>>,
45}
46
47impl AuthStore {
48    /// Create a new empty auth store.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Generate a new unique ID.
54    fn next_id(&self) -> u64 {
55        self.next_id.fetch_add(1, Ordering::SeqCst) + 1
56    }
57
58    // ==================== Organizations ====================
59
60    /// Create a new organization.
61    pub fn create_organization(
62        &self,
63        name: String,
64        display_name: String,
65        creator: String,
66    ) -> Result<Organization> {
67        // Check if name already exists
68        if self.org_name_index.read().contains_key(&name) {
69            return Err(AuthError::AlreadyExists(format!("organization '{}'", name)));
70        }
71
72        let id = self.next_id();
73        let org = Organization::new(id, name.clone(), display_name, creator);
74
75        self.organizations.write().insert(id, org.clone());
76        self.org_name_index.write().insert(name, id);
77
78        Ok(org)
79    }
80
81    /// Get an organization by ID.
82    pub fn get_organization(&self, id: u64) -> Option<Organization> {
83        self.organizations.read().get(&id).cloned()
84    }
85
86    /// Get an organization by name.
87    pub fn get_organization_by_name(&self, name: &str) -> Option<Organization> {
88        let id = self.org_name_index.read().get(name).copied()?;
89        self.get_organization(id)
90    }
91
92    /// List all organizations.
93    pub fn list_organizations(&self) -> Vec<Organization> {
94        self.organizations.read().values().cloned().collect()
95    }
96
97    /// List organizations a user belongs to.
98    pub fn list_user_organizations(&self, user: &str) -> Vec<Organization> {
99        self.organizations
100            .read()
101            .values()
102            .filter(|org| org.is_member(user))
103            .cloned()
104            .collect()
105    }
106
107    /// Update an organization.
108    pub fn update_organization(
109        &self,
110        id: u64,
111        display_name: Option<String>,
112        description: Option<String>,
113    ) -> Result<Organization> {
114        let mut orgs = self.organizations.write();
115        let org = orgs
116            .get_mut(&id)
117            .ok_or_else(|| AuthError::NotFound(format!("organization {}", id)))?;
118
119        if let Some(dn) = display_name {
120            org.display_name = dn;
121        }
122        if let Some(desc) = description {
123            org.description = Some(desc);
124        }
125        org.updated_at = Self::now();
126
127        Ok(org.clone())
128    }
129
130    /// Delete an organization.
131    pub fn delete_organization(&self, id: u64) -> Result<()> {
132        let mut orgs = self.organizations.write();
133        let org = orgs
134            .remove(&id)
135            .ok_or_else(|| AuthError::NotFound(format!("organization {}", id)))?;
136
137        // Remove from name index
138        self.org_name_index.write().remove(&org.name);
139
140        // Remove all teams
141        let team_ids: Vec<u64> = org.teams.iter().copied().collect();
142        for team_id in team_ids {
143            self.teams.write().remove(&team_id);
144        }
145
146        Ok(())
147    }
148
149    /// Add a member to an organization.
150    pub fn add_org_member(&self, org_id: u64, member: OrgMember) -> Result<()> {
151        let mut orgs = self.organizations.write();
152        let org = orgs
153            .get_mut(&org_id)
154            .ok_or_else(|| AuthError::NotFound(format!("organization {}", org_id)))?;
155
156        if !org.add_member(member.clone()) {
157            return Err(AuthError::AlreadyExists(format!(
158                "member '{}'",
159                member.user
160            )));
161        }
162
163        Ok(())
164    }
165
166    /// Remove a member from an organization.
167    pub fn remove_org_member(&self, org_id: u64, user: &str) -> Result<()> {
168        let mut orgs = self.organizations.write();
169        let org = orgs
170            .get_mut(&org_id)
171            .ok_or_else(|| AuthError::NotFound(format!("organization {}", org_id)))?;
172
173        org.remove_member(user).map_err(|_| AuthError::LastOwner)?;
174
175        Ok(())
176    }
177
178    /// Update a member's role in an organization.
179    pub fn update_org_member_role(&self, org_id: u64, user: &str, role: OrgRole) -> Result<()> {
180        let mut orgs = self.organizations.write();
181        let org = orgs
182            .get_mut(&org_id)
183            .ok_or_else(|| AuthError::NotFound(format!("organization {}", org_id)))?;
184
185        org.update_member_role(user, role)
186            .map_err(|_| AuthError::LastOwner)?;
187
188        Ok(())
189    }
190
191    // ==================== Teams ====================
192
193    /// Create a new team.
194    pub fn create_team(
195        &self,
196        org_id: u64,
197        name: String,
198        permission: Permission,
199        created_by: String,
200    ) -> Result<Team> {
201        // Verify org exists
202        let mut orgs = self.organizations.write();
203        let org = orgs
204            .get_mut(&org_id)
205            .ok_or_else(|| AuthError::NotFound(format!("organization {}", org_id)))?;
206
207        // Check for duplicate name
208        let teams = self.teams.read();
209        if teams.values().any(|t| t.org_id == org_id && t.name == name) {
210            return Err(AuthError::AlreadyExists(format!(
211                "team '{}' in organization",
212                name
213            )));
214        }
215        drop(teams);
216
217        let id = self.next_id();
218        let team = Team::new(id, org_id, name, permission, created_by);
219
220        org.add_team(id);
221        self.teams.write().insert(id, team.clone());
222
223        Ok(team)
224    }
225
226    /// Get a team by ID.
227    pub fn get_team(&self, id: u64) -> Option<Team> {
228        self.teams.read().get(&id).cloned()
229    }
230
231    /// Get a team by org and name.
232    pub fn get_team_by_name(&self, org_id: u64, name: &str) -> Option<Team> {
233        self.teams
234            .read()
235            .values()
236            .find(|t| t.org_id == org_id && t.name == name)
237            .cloned()
238    }
239
240    /// List teams in an organization.
241    pub fn list_teams(&self, org_id: u64) -> Vec<Team> {
242        self.teams
243            .read()
244            .values()
245            .filter(|t| t.org_id == org_id)
246            .cloned()
247            .collect()
248    }
249
250    /// List teams a user belongs to.
251    pub fn list_user_teams(&self, user: &str) -> Vec<Team> {
252        self.teams
253            .read()
254            .values()
255            .filter(|t| t.is_member(user))
256            .cloned()
257            .collect()
258    }
259
260    /// Update a team.
261    pub fn update_team(
262        &self,
263        id: u64,
264        name: Option<String>,
265        description: Option<String>,
266        permission: Option<Permission>,
267    ) -> Result<Team> {
268        let mut teams = self.teams.write();
269        let team = teams
270            .get_mut(&id)
271            .ok_or_else(|| AuthError::NotFound(format!("team {}", id)))?;
272
273        if let Some(n) = name {
274            team.name = n;
275        }
276        if let Some(d) = description {
277            team.description = Some(d);
278        }
279        if let Some(p) = permission {
280            team.permission = p;
281        }
282        team.updated_at = Self::now();
283
284        Ok(team.clone())
285    }
286
287    /// Delete a team.
288    pub fn delete_team(&self, id: u64) -> Result<()> {
289        let team = self
290            .teams
291            .write()
292            .remove(&id)
293            .ok_or_else(|| AuthError::NotFound(format!("team {}", id)))?;
294
295        // Remove from org
296        if let Some(org) = self.organizations.write().get_mut(&team.org_id) {
297            org.remove_team(id);
298        }
299
300        Ok(())
301    }
302
303    /// Add a member to a team.
304    pub fn add_team_member(&self, team_id: u64, user: String) -> Result<()> {
305        let mut teams = self.teams.write();
306        let team = teams
307            .get_mut(&team_id)
308            .ok_or_else(|| AuthError::NotFound(format!("team {}", team_id)))?;
309
310        if !team.add_member(user.clone()) {
311            return Err(AuthError::AlreadyExists(format!(
312                "member '{}' in team",
313                user
314            )));
315        }
316
317        Ok(())
318    }
319
320    /// Remove a member from a team.
321    pub fn remove_team_member(&self, team_id: u64, user: &str) -> Result<()> {
322        let mut teams = self.teams.write();
323        let team = teams
324            .get_mut(&team_id)
325            .ok_or_else(|| AuthError::NotFound(format!("team {}", team_id)))?;
326
327        if !team.remove_member(user) {
328            return Err(AuthError::NotFound(format!("member '{}' in team", user)));
329        }
330
331        Ok(())
332    }
333
334    /// Add a repository to a team.
335    pub fn add_team_repo(&self, team_id: u64, repo_key: String) -> Result<()> {
336        let mut teams = self.teams.write();
337        let team = teams
338            .get_mut(&team_id)
339            .ok_or_else(|| AuthError::NotFound(format!("team {}", team_id)))?;
340
341        team.add_repo(repo_key);
342        Ok(())
343    }
344
345    /// Remove a repository from a team.
346    pub fn remove_team_repo(&self, team_id: u64, repo_key: &str) -> Result<()> {
347        let mut teams = self.teams.write();
348        let team = teams
349            .get_mut(&team_id)
350            .ok_or_else(|| AuthError::NotFound(format!("team {}", team_id)))?;
351
352        if !team.remove_repo(repo_key) {
353            return Err(AuthError::NotFound(format!("repo '{}' in team", repo_key)));
354        }
355
356        Ok(())
357    }
358
359    // ==================== Collaborators ====================
360
361    /// Add or update a collaborator.
362    pub fn set_collaborator(
363        &self,
364        repo_key: String,
365        user: String,
366        permission: Permission,
367        added_by: String,
368    ) -> Collaborator {
369        let key = (repo_key.clone(), user.clone());
370        let mut collabs = self.collaborators.write();
371
372        if let Some(existing) = collabs.get_mut(&key) {
373            existing.permission = permission;
374            existing.updated_at = Self::now();
375            return existing.clone();
376        }
377
378        let id = self.next_id();
379        let collab = Collaborator::new(id, repo_key.clone(), user.clone(), permission, added_by);
380        collabs.insert(key, collab.clone());
381
382        // Update index
383        let mut index = self.collaborator_index.write();
384        index.entry(repo_key).or_default().push((user, permission));
385
386        collab
387    }
388
389    /// Get a collaborator.
390    pub fn get_collaborator(&self, repo_key: &str, user: &str) -> Option<Collaborator> {
391        let key = (repo_key.to_string(), user.to_string());
392        self.collaborators.read().get(&key).cloned()
393    }
394
395    /// List collaborators for a repository.
396    pub fn list_collaborators(&self, repo_key: &str) -> Vec<Collaborator> {
397        self.collaborators
398            .read()
399            .values()
400            .filter(|c| c.repo_key == repo_key)
401            .cloned()
402            .collect()
403    }
404
405    /// Remove a collaborator.
406    pub fn remove_collaborator(&self, repo_key: &str, user: &str) -> Result<()> {
407        let key = (repo_key.to_string(), user.to_string());
408        if self.collaborators.write().remove(&key).is_none() {
409            return Err(AuthError::NotFound(format!(
410                "collaborator '{}' on '{}'",
411                user, repo_key
412            )));
413        }
414
415        // Update index
416        let mut index = self.collaborator_index.write();
417        if let Some(users) = index.get_mut(repo_key) {
418            users.retain(|(u, _)| u != user);
419        }
420
421        Ok(())
422    }
423
424    // ==================== Branch Protection ====================
425
426    /// Set branch protection for a pattern.
427    pub fn set_branch_protection(&self, repo_key: String, pattern: String) -> BranchProtection {
428        let key = (repo_key.clone(), pattern.clone());
429        let mut protections = self.branch_protections.write();
430
431        if let Some(existing) = protections.get(&key) {
432            return existing.clone();
433        }
434
435        let id = self.next_id();
436        let protection = BranchProtection::new(id, repo_key, pattern);
437        protections.insert(key, protection.clone());
438        protection
439    }
440
441    /// Get branch protection for a pattern.
442    pub fn get_branch_protection(&self, repo_key: &str, pattern: &str) -> Option<BranchProtection> {
443        let key = (repo_key.to_string(), pattern.to_string());
444        self.branch_protections.read().get(&key).cloned()
445    }
446
447    /// Find branch protection that matches a branch name.
448    pub fn find_branch_protection(&self, repo_key: &str, branch: &str) -> Option<BranchProtection> {
449        self.branch_protections
450            .read()
451            .values()
452            .filter(|p| p.repo_key == repo_key && p.matches(branch))
453            .max_by_key(|p| p.pattern.len()) // Most specific match
454            .cloned()
455    }
456
457    /// List all branch protections for a repository.
458    pub fn list_branch_protections(&self, repo_key: &str) -> Vec<BranchProtection> {
459        self.branch_protections
460            .read()
461            .values()
462            .filter(|p| p.repo_key == repo_key)
463            .cloned()
464            .collect()
465    }
466
467    /// Update branch protection.
468    pub fn update_branch_protection(
469        &self,
470        repo_key: &str,
471        pattern: &str,
472        update: impl FnOnce(&mut BranchProtection),
473    ) -> Result<BranchProtection> {
474        let key = (repo_key.to_string(), pattern.to_string());
475        let mut protections = self.branch_protections.write();
476        let protection = protections
477            .get_mut(&key)
478            .ok_or_else(|| AuthError::NotFound(format!("branch protection for '{}'", pattern)))?;
479
480        update(protection);
481        protection.updated_at = Self::now();
482
483        Ok(protection.clone())
484    }
485
486    /// Remove branch protection.
487    pub fn remove_branch_protection(&self, repo_key: &str, pattern: &str) -> Result<()> {
488        let key = (repo_key.to_string(), pattern.to_string());
489        if self.branch_protections.write().remove(&key).is_none() {
490            return Err(AuthError::NotFound(format!(
491                "branch protection for '{}'",
492                pattern
493            )));
494        }
495        Ok(())
496    }
497
498    // ==================== Webhooks ====================
499
500    /// Create a webhook.
501    pub fn create_webhook(
502        &self,
503        repo_key: String,
504        url: String,
505        events: std::collections::HashSet<crate::webhook::WebhookEvent>,
506    ) -> Webhook {
507        let id = self.next_id();
508        let webhook = Webhook::new(id, repo_key.clone(), url, events);
509
510        self.webhooks.write().insert(id, webhook.clone());
511
512        // Update index
513        self.webhook_index
514            .write()
515            .entry(repo_key)
516            .or_default()
517            .push(id);
518
519        webhook
520    }
521
522    /// Get a webhook by ID.
523    pub fn get_webhook(&self, id: u64) -> Option<Webhook> {
524        self.webhooks.read().get(&id).cloned()
525    }
526
527    /// List webhooks for a repository.
528    pub fn list_webhooks(&self, repo_key: &str) -> Vec<Webhook> {
529        self.webhooks
530            .read()
531            .values()
532            .filter(|w| w.repo_key == repo_key)
533            .cloned()
534            .collect()
535    }
536
537    /// Update a webhook.
538    pub fn update_webhook(&self, id: u64, update: impl FnOnce(&mut Webhook)) -> Result<Webhook> {
539        let mut webhooks = self.webhooks.write();
540        let webhook = webhooks
541            .get_mut(&id)
542            .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
543
544        update(webhook);
545        webhook.updated_at = Self::now();
546
547        Ok(webhook.clone())
548    }
549
550    /// Delete a webhook.
551    pub fn delete_webhook(&self, id: u64) -> Result<()> {
552        let webhook = self
553            .webhooks
554            .write()
555            .remove(&id)
556            .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
557
558        // Update index
559        if let Some(ids) = self.webhook_index.write().get_mut(&webhook.repo_key) {
560            ids.retain(|&wid| wid != id);
561        }
562
563        Ok(())
564    }
565
566    /// Find webhooks that should fire for an event.
567    pub fn find_webhooks_for_event(
568        &self,
569        repo_key: &str,
570        event: crate::webhook::WebhookEvent,
571    ) -> Vec<Webhook> {
572        self.webhooks
573            .read()
574            .values()
575            .filter(|w| w.repo_key == repo_key && w.should_fire(event))
576            .cloned()
577            .collect()
578    }
579
580    // ==================== Permission Resolution ====================
581
582    /// Check if a user has at least the required permission on a repository.
583    ///
584    /// Permission resolution order:
585    /// 1. Repository owner always has Admin
586    /// 2. Direct collaborator permission
587    /// 3. Team permission (highest wins)
588    /// 4. Organization membership (if org owns repo)
589    pub fn check_permission(&self, user: &str, repo_key: &str, required: Permission) -> bool {
590        // Check if user is repo owner
591        if let Some((owner, _)) = repo_key.split_once('/') {
592            if owner == user {
593                return true; // Owner has Admin access
594            }
595
596            // Check if owner is an org
597            if let Some(org) = self.get_organization_by_name(owner) {
598                // Org owners and admins have Admin access to all org repos
599                if org.is_admin(user) {
600                    return true;
601                }
602                // Org members have Read access by default
603                if org.is_member(user) && required == Permission::Read {
604                    return true;
605                }
606            }
607        }
608
609        // Check direct collaborator permission
610        if let Some(collab) = self.get_collaborator(repo_key, user) {
611            if collab.has_permission(required) {
612                return true;
613            }
614        }
615
616        // Check team permissions
617        let user_teams = self.list_user_teams(user);
618        for team in user_teams {
619            if let Some(perm) = team.get_repo_permission(repo_key) {
620                if perm.has(required) {
621                    return true;
622                }
623            }
624        }
625
626        false
627    }
628
629    /// Get the effective permission for a user on a repository.
630    pub fn get_effective_permission(&self, user: &str, repo_key: &str) -> Option<Permission> {
631        let mut best: Option<Permission> = None;
632
633        // Check if user is repo owner
634        if let Some((owner, _)) = repo_key.split_once('/') {
635            if owner == user {
636                return Some(Permission::Admin);
637            }
638
639            // Check if owner is an org
640            if let Some(org) = self.get_organization_by_name(owner) {
641                if org.is_admin(user) {
642                    return Some(Permission::Admin);
643                }
644                if org.is_member(user) {
645                    best = Some(Permission::Read);
646                }
647            }
648        }
649
650        // Check direct collaborator permission
651        if let Some(collab) = self.get_collaborator(repo_key, user) {
652            if best.is_none() || collab.permission > best.unwrap() {
653                best = Some(collab.permission);
654            }
655        }
656
657        // Check team permissions
658        let user_teams = self.list_user_teams(user);
659        for team in user_teams {
660            if let Some(perm) = team.get_repo_permission(repo_key) {
661                if best.is_none() || perm > best.unwrap() {
662                    best = Some(perm);
663                }
664            }
665        }
666
667        best
668    }
669
670    fn now() -> u64 {
671        std::time::SystemTime::now()
672            .duration_since(std::time::UNIX_EPOCH)
673            .unwrap_or_default()
674            .as_secs()
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn test_organization_crud() {
684        let store = AuthStore::new();
685
686        // Create
687        let org = store
688            .create_organization("acme".into(), "Acme Corp".into(), "owner".into())
689            .unwrap();
690        assert_eq!(org.name, "acme");
691
692        // Duplicate fails
693        assert!(store
694            .create_organization("acme".into(), "Another".into(), "other".into())
695            .is_err());
696
697        // Get by ID
698        let org2 = store.get_organization(org.id).unwrap();
699        assert_eq!(org2.name, "acme");
700
701        // Get by name
702        let org3 = store.get_organization_by_name("acme").unwrap();
703        assert_eq!(org3.id, org.id);
704
705        // Update
706        let org4 = store
707            .update_organization(org.id, Some("ACME Inc".into()), Some("Description".into()))
708            .unwrap();
709        assert_eq!(org4.display_name, "ACME Inc");
710        assert_eq!(org4.description, Some("Description".into()));
711
712        // Delete
713        store.delete_organization(org.id).unwrap();
714        assert!(store.get_organization(org.id).is_none());
715    }
716
717    #[test]
718    fn test_team_crud() {
719        let store = AuthStore::new();
720        let org = store
721            .create_organization("acme".into(), "Acme Corp".into(), "owner".into())
722            .unwrap();
723
724        // Create team
725        let team = store
726            .create_team(org.id, "backend".into(), Permission::Write, "owner".into())
727            .unwrap();
728        assert_eq!(team.name, "backend");
729
730        // List teams
731        let teams = store.list_teams(org.id);
732        assert_eq!(teams.len(), 1);
733
734        // Add members
735        store.add_team_member(team.id, "user1".into()).unwrap();
736        store.add_team_member(team.id, "user2".into()).unwrap();
737
738        let team = store.get_team(team.id).unwrap();
739        assert!(team.is_member("user1"));
740        assert!(team.is_member("user2"));
741
742        // Add repos
743        store.add_team_repo(team.id, "acme/api".into()).unwrap();
744        let team = store.get_team(team.id).unwrap();
745        assert!(team.has_repo("acme/api"));
746
747        // Delete team
748        store.delete_team(team.id).unwrap();
749        assert!(store.get_team(team.id).is_none());
750    }
751
752    #[test]
753    fn test_permission_resolution() {
754        let store = AuthStore::new();
755
756        // Owner always has admin
757        assert!(store.check_permission("alice", "alice/repo", Permission::Admin));
758
759        // Non-owner has no access by default
760        assert!(!store.check_permission("bob", "alice/repo", Permission::Read));
761
762        // Add collaborator
763        store.set_collaborator(
764            "alice/repo".into(),
765            "bob".into(),
766            Permission::Write,
767            "alice".into(),
768        );
769        assert!(store.check_permission("bob", "alice/repo", Permission::Read));
770        assert!(store.check_permission("bob", "alice/repo", Permission::Write));
771        assert!(!store.check_permission("bob", "alice/repo", Permission::Admin));
772
773        // Create org with team
774        let org = store
775            .create_organization("acme".into(), "Acme".into(), "owner".into())
776            .unwrap();
777        let team = store
778            .create_team(org.id, "devs".into(), Permission::Write, "owner".into())
779            .unwrap();
780        store.add_team_member(team.id, "charlie".into()).unwrap();
781        store.add_team_repo(team.id, "acme/api".into()).unwrap();
782
783        // Charlie has team access
784        assert!(store.check_permission("charlie", "acme/api", Permission::Write));
785
786        // Org admin has full access
787        store
788            .add_org_member(
789                org.id,
790                OrgMember::new("dave".into(), OrgRole::Admin, "owner".into()),
791            )
792            .unwrap();
793        assert!(store.check_permission("dave", "acme/api", Permission::Admin));
794    }
795
796    #[test]
797    fn test_branch_protection() {
798        let store = AuthStore::new();
799
800        let protection = store.set_branch_protection("alice/repo".into(), "main".into());
801        assert!(protection.matches("main"));
802
803        // Find matching protection
804        let found = store.find_branch_protection("alice/repo", "main");
805        assert!(found.is_some());
806
807        let not_found = store.find_branch_protection("alice/repo", "develop");
808        assert!(not_found.is_none());
809
810        // Update protection
811        let updated = store
812            .update_branch_protection("alice/repo", "main", |p| {
813                p.required_reviews = 2;
814                p.require_code_owner_review = true;
815            })
816            .unwrap();
817        assert_eq!(updated.required_reviews, 2);
818        assert!(updated.require_code_owner_review);
819
820        // Remove
821        store
822            .remove_branch_protection("alice/repo", "main")
823            .unwrap();
824        assert!(store.find_branch_protection("alice/repo", "main").is_none());
825    }
826
827    #[test]
828    fn test_webhooks() {
829        use crate::webhook::WebhookEvent;
830        use std::collections::HashSet;
831
832        let store = AuthStore::new();
833
834        let mut events = HashSet::new();
835        events.insert(WebhookEvent::Push);
836        events.insert(WebhookEvent::PullRequest);
837
838        let webhook = store.create_webhook(
839            "alice/repo".into(),
840            "https://example.com/hook".into(),
841            events,
842        );
843        assert_eq!(webhook.id, 1);
844
845        // Find webhooks for event
846        let push_hooks = store.find_webhooks_for_event("alice/repo", WebhookEvent::Push);
847        assert_eq!(push_hooks.len(), 1);
848
849        let issue_hooks = store.find_webhooks_for_event("alice/repo", WebhookEvent::Issue);
850        assert_eq!(issue_hooks.len(), 0);
851
852        // Update
853        store.update_webhook(webhook.id, |w| w.disable()).unwrap();
854        let disabled_hooks = store.find_webhooks_for_event("alice/repo", WebhookEvent::Push);
855        assert_eq!(disabled_hooks.len(), 0);
856
857        // Delete
858        store.delete_webhook(webhook.id).unwrap();
859        assert!(store.get_webhook(webhook.id).is_none());
860    }
861}