| | @@ -150,6 +150,57 @@ pub async fn view_repo( |
| 150 | 150 | })) |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | +/// A repository as it appears in a listing. |
| 154 | +/// |
| 155 | +/// Leaner than [`RepoView`] on purpose: the owning handle and whether the viewer owns |
| 156 | +/// it are constant across a listing and already known to whatever is rendering it. |
| 157 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 158 | +pub struct RepoSummary { |
| 159 | + pub name: RepoName, |
| 160 | + pub description: Option<String>, |
| 161 | + pub visibility: Visibility, |
| 162 | +} |
| 163 | + |
| 164 | +/// Every repository under a handle that the viewer is allowed to see, ordered by name. |
| 165 | +/// |
| 166 | +/// `Ok(None)` means no such handle — distinct from `Ok(Some(vec![]))`, which means the |
| 167 | +/// handle exists and the viewer can see nothing under it. A caller serving `/api` needs |
| 168 | +/// that difference to answer 404 rather than an empty list. |
| 169 | +/// |
| 170 | +/// **The filtering happens here, not in the port.** `list_by_org` deliberately returns |
| 171 | +/// everything, so that the page and `/api` cannot end up applying different rules. |
| 172 | +/// A viewer who may see nothing gets an empty list, never a count or a hint — that |
| 173 | +/// would leak both the existence and the number of private repositories. |
| 174 | +pub async fn list_repos( |
| 175 | + handle: &OrgName, |
| 176 | + actor: &Actor, |
| 177 | + orgs: &impl OrgRepository, |
| 178 | + memberships: &impl MembershipRepository, |
| 179 | + repos: &impl RepoRepository, |
| 180 | +) -> Result<Option<Vec<RepoSummary>>> { |
| 181 | + let Some(org) = orgs.find_by_name(handle).await? else { |
| 182 | + return Ok(None); |
| 183 | + }; |
| 184 | + |
| 185 | + // Resolved once rather than per row: membership cannot change mid-listing, and |
| 186 | + // asking per repository would be a query per repository. |
| 187 | + let is_member = is_org_member(&org, actor, memberships).await?; |
| 188 | + |
| 189 | + Ok(Some( |
| 190 | + repos |
| 191 | + .list_by_org(&org.id) |
| 192 | + .await? |
| 193 | + .into_iter() |
| 194 | + .filter(|repo| repo.visibility.is_public() || is_member) |
| 195 | + .map(|repo| RepoSummary { |
| 196 | + name: repo.name, |
| 197 | + description: repo.description, |
| 198 | + visibility: repo.visibility, |
| 199 | + }) |
| 200 | + .collect(), |
| 201 | + )) |
| 202 | +} |
| 203 | + |
| 153 | 204 | fn taken() -> Error { |
| 154 | 205 | DomainError::AlreadyExists { |
| 155 | 206 | entity: "repository", |
| | @@ -731,4 +782,153 @@ mod tests { |
| 731 | 782 | |
| 732 | 783 | assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some()); |
| 733 | 784 | } |
| 785 | + |
| 786 | + // --- list_repos ------------------------------------------------------------ |
| 787 | + |
| 788 | + impl Fixture { |
| 789 | + async fn list(&self, actor: &Actor) -> Vec<RepoSummary> { |
| 790 | + list_repos( |
| 791 | + &self.handle, |
| 792 | + actor, |
| 793 | + &self.orgs, |
| 794 | + &self.memberships, |
| 795 | + &self.repos, |
| 796 | + ) |
| 797 | + .await |
| 798 | + .expect("listing should not error") |
| 799 | + .expect("the handle exists") |
| 800 | + } |
| 801 | + |
| 802 | + fn names(summaries: &[RepoSummary]) -> Vec<&str> { |
| 803 | + summaries.iter().map(|repo| repo.name.as_str()).collect() |
| 804 | + } |
| 805 | + } |
| 806 | + |
| 807 | + /// Two public and one private, created out of alphabetical order. |
| 808 | + async fn mixed() -> Fixture { |
| 809 | + let f = fixture().await; |
| 810 | + f.create_with(Visibility::Public, "zebra").await; |
| 811 | + f.create_with(Visibility::Private, "secret").await; |
| 812 | + f.create_with(Visibility::Public, "alpha").await; |
| 813 | + f |
| 814 | + } |
| 815 | + |
| 816 | + #[tokio::test] |
| 817 | + async fn outsiders_see_only_public_repositories() { |
| 818 | + let f = mixed().await; |
| 819 | + |
| 820 | + for actor in [&Actor::Anonymous, &f.stranger] { |
| 821 | + let listed = f.list(actor).await; |
| 822 | + assert_eq!( |
| 823 | + Fixture::names(&listed), |
| 824 | + vec!["alpha", "zebra"], |
| 825 | + "{actor:?} should see only the public repositories" |
| 826 | + ); |
| 827 | + } |
| 828 | + } |
| 829 | + |
| 830 | + #[tokio::test] |
| 831 | + async fn members_and_owners_see_private_repositories_too() { |
| 832 | + let f = mixed().await; |
| 833 | + |
| 834 | + for actor in [&f.member, &f.owner] { |
| 835 | + let listed = f.list(actor).await; |
| 836 | + assert_eq!( |
| 837 | + Fixture::names(&listed), |
| 838 | + vec!["alpha", "secret", "zebra"], |
| 839 | + "{actor:?} should see everything" |
| 840 | + ); |
| 841 | + } |
| 842 | + } |
| 843 | + |
| 844 | + #[tokio::test] |
| 845 | + async fn listings_are_ordered_by_name() { |
| 846 | + // Created zebra, secret, alpha — the order out is not the order in. |
| 847 | + let f = mixed().await; |
| 848 | + |
| 849 | + assert_eq!( |
| 850 | + Fixture::names(&f.list(&f.owner).await), |
| 851 | + vec!["alpha", "secret", "zebra"] |
| 852 | + ); |
| 853 | + } |
| 854 | + |
| 855 | + #[tokio::test] |
| 856 | + async fn a_viewer_who_may_see_nothing_gets_an_empty_list() { |
| 857 | + // Not a count, not a hint. Either would leak that private repositories exist |
| 858 | + // and how many. |
| 859 | + let f = fixture().await; |
| 860 | + f.create_with(Visibility::Private, "secret").await; |
| 861 | + f.create_with(Visibility::Private, "other").await; |
| 862 | + |
| 863 | + assert!(f.list(&Actor::Anonymous).await.is_empty()); |
| 864 | + } |
| 865 | + |
| 866 | + #[tokio::test] |
| 867 | + async fn a_handle_with_no_repositories_lists_nothing() { |
| 868 | + let f = fixture().await; |
| 869 | + |
| 870 | + assert!(f.list(&f.owner).await.is_empty()); |
| 871 | + } |
| 872 | + |
| 873 | + #[tokio::test] |
| 874 | + async fn an_unknown_handle_is_none_not_an_empty_list() { |
| 875 | + // `/api` has to answer 404 for a handle that does not exist rather than `[]`. |
| 876 | + let f = fixture().await; |
| 877 | + let missing = OrgName::new("nobody").expect("valid handle"); |
| 878 | + |
| 879 | + let listed = list_repos(&missing, &f.owner, &f.orgs, &f.memberships, &f.repos) |
| 880 | + .await |
| 881 | + .expect("listing should not error"); |
| 882 | + |
| 883 | + assert!(listed.is_none()); |
| 884 | + } |
| 885 | + |
| 886 | + #[tokio::test] |
| 887 | + async fn a_summary_carries_what_a_listing_renders() { |
| 888 | + let f = fixture().await; |
| 889 | + f.create( |
| 890 | + &f.owner, |
| 891 | + &NewRepo { |
| 892 | + name: "steid".to_owned(), |
| 893 | + description: Some("A gitforge.".to_owned()), |
| 894 | + visibility: Visibility::Private, |
| 895 | + }, |
| 896 | + ) |
| 897 | + .await |
| 898 | + .expect("should create"); |
| 899 | + |
| 900 | + let listed = f.list(&f.owner).await; |
| 901 | + let summary = listed.first().expect("one repository"); |
| 902 | + |
| 903 | + assert_eq!(summary.name.as_str(), "steid"); |
| 904 | + assert_eq!(summary.description.as_deref(), Some("A gitforge.")); |
| 905 | + assert_eq!(summary.visibility, Visibility::Private); |
| 906 | + } |
| 907 | + |
| 908 | + #[tokio::test] |
| 909 | + async fn listing_only_covers_the_handle_asked_for() { |
| 910 | + let f = fixture().await; |
| 911 | + f.create_with(Visibility::Public, "mine").await; |
| 912 | + |
| 913 | + let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org"); |
| 914 | + f.orgs.save(&other).await.expect("save org"); |
| 915 | + f.repos |
| 916 | + .save( |
| 917 | + &Repository::new( |
| 918 | + RepoId::generate(), |
| 919 | + other.id.clone(), |
| 920 | + "theirs", |
| 921 | + None, |
| 922 | + Visibility::Public, |
| 923 | + ) |
| 924 | + .expect("valid repo"), |
| 925 | + ) |
| 926 | + .await |
| 927 | + .expect("save repo"); |
| 928 | + |
| 929 | + assert_eq!( |
| 930 | + Fixture::names(&f.list(&Actor::Anonymous).await), |
| 931 | + vec!["mine"] |
| 932 | + ); |
| 933 | + } |
| 734 | 934 | } |