steid

@jamesgill /

28.3 KBCode·Blame·Raw
1use crate::domain::{
2 Actor, DomainError, OrgName, RepoId, RepoName, Repository, Visibility,
3 repository::{MembershipRepository, OrgRepository, RepoRepository},
4};
5
6use super::{
7 authz::{is_org_member, is_org_owner},
8 error::{Error, Result},
9 port::{GitStorage, GitStorageError},
10};
11
12/// The repository a visitor is asking to create.
13///
14/// Raw input: `name` is whatever was typed, and normalising it is
15/// [`Repository::new`]'s job, not the caller's.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct NewRepo {
18 pub name: String,
19 pub description: Option<String>,
20 pub visibility: Visibility,
21}
22
23/// Creates a repository as a record and as a bare repo on disk.
24///
25/// Owner only. A member's read access is not permission to add to someone's portfolio.
26///
27/// Returns the created [`Repository`] rather than `()` because the name normalises on
28/// the way in — someone who typed `MyRepo` has to be redirected to `myrepo`, and only
29/// the returned value knows that.
30///
31/// **The two writes cannot share a transaction.** The bare repo is created first and
32/// removed again if the record fails to save, per
33/// `plans/architecture.md#db-plus-filesystem-writes`. If the process dies between them
34/// the directory is orphaned; that hole is known and unclosed.
35pub async fn create_repo(
36 actor: &Actor,
37 handle: &OrgName,
38 spec: &NewRepo,
39 orgs: &impl OrgRepository,
40 memberships: &impl MembershipRepository,
41 repos: &impl RepoRepository,
42 storage: &impl GitStorage,
43) -> Result<Repository> {
44 let Some(org) = orgs.find_by_name(handle).await? else {
45 return Err(DomainError::NotFound {
46 entity: "repository owner",
47 }
48 .into());
49 };
50
51 if !is_org_owner(&org, actor, memberships).await? {
52 return Err(DomainError::Forbidden.into());
53 }
54
55 // Validated before anything is written, so a bad name leaves neither a row nor a
56 // directory behind.
57 let repo = Repository::new(
58 RepoId::generate(),
59 org.id.clone(),
60 spec.name.clone(),
61 spec.description.clone(),
62 spec.visibility,
63 )?;
64
65 if repos
66 .find_by_org_and_name(&org.id, &repo.name)
67 .await?
68 .is_some()
69 {
70 return Err(taken());
71 }
72
73 storage
74 .init_bare(handle, &repo.name)
75 .await
76 .map_err(|error| {
77 match error {
78 // No record, but something is already on disk: an orphan from a create that
79 // died between the two writes. The name really is unavailable, so this is
80 // what the visitor is told — at the cost of an orphan being
81 // indistinguishable from a duplicate from the outside. The durable fix is
82 // the reconciliation sweep noted in architecture.md.
83 GitStorageError::AlreadyExists => taken(),
84 other => Error::GitStorage(other),
85 }
86 })?;
87
88 if let Err(error) = repos.save(&repo).await {
89 // Compensation. Safe to delete by path because `init_bare` just proved nothing
90 // was there — this can never remove a repository that won a race, because the
91 // loser of that race never gets past `init_bare`.
92 //
93 // Best effort: if the removal also fails the caller still needs the error that
94 // started this, and an orphaned directory is the documented failure mode.
95 let _ = storage.remove(handle, &repo.name).await;
96 return Err(error.into());
97 }
98
99 Ok(repo)
100}
101
102/// A repository as a viewer is allowed to see it.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct RepoView {
105 /// The owning handle, carried so a page can build links without a second lookup.
106 pub handle: OrgName,
107 pub name: RepoName,
108 pub description: Option<String>,
109 pub visibility: Visibility,
110 /// Whether the viewer may change this repository. Decided here so a page and
111 /// `/api` cannot disagree about who sees a management control.
112 pub viewer_is_owner: bool,
113}
114
115/// Resolves a handle and name into a repository the viewer may see.
116///
117/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", and
118/// the caller must render them identically. Distinguishing them would confirm that a
119/// private repository exists and reveal its name, which is the thing being protected —
120/// a private repo has to be absent, not merely unlinked.
121///
122/// Private repositories are visible to any member of the owning organisation, not only
123/// its owner: seeing is weaker than changing.
124pub async fn view_repo(
125 handle: &OrgName,
126 name: &RepoName,
127 actor: &Actor,
128 orgs: &impl OrgRepository,
129 memberships: &impl MembershipRepository,
130 repos: &impl RepoRepository,
131) -> Result<Option<RepoView>> {
132 let Some(org) = orgs.find_by_name(handle).await? else {
133 return Ok(None);
134 };
135
136 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
137 return Ok(None);
138 };
139
140 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
141 return Ok(None);
142 }
143
144 Ok(Some(RepoView {
145 handle: org.name.clone(),
146 name: repo.name,
147 description: repo.description,
148 visibility: repo.visibility,
149 viewer_is_owner: is_org_owner(&org, actor, memberships).await?,
150 }))
151}
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)]
158pub 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.
174pub 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
204fn taken() -> Error {
205 DomainError::AlreadyExists {
206 entity: "repository",
207 }
208 .into()
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::{
215 domain::{
216 Membership, MembershipId, OrgId, Organization, RepoName, Role, UserId,
217 repository::{RepositoryError, RepositoryResult},
218 },
219 infrastructure::{
220 git::{DiskGitStorage, InMemoryGitStorage},
221 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
222 },
223 };
224
225 /// A `RepoRepository` whose `save` always fails, for exercising compensation.
226 ///
227 /// Test-local on purpose: fault injection does not belong in the shared fake, where
228 /// every other test would have to know about it.
229 #[derive(Debug, Default)]
230 struct FailingRepoRepo;
231
232 impl RepoRepository for FailingRepoRepo {
233 async fn find_by_id(&self, _id: &RepoId) -> RepositoryResult<Option<Repository>> {
234 Ok(None)
235 }
236
237 async fn find_by_org_and_name(
238 &self,
239 _org_id: &OrgId,
240 _name: &RepoName,
241 ) -> RepositoryResult<Option<Repository>> {
242 Ok(None)
243 }
244
245 async fn list_by_org(&self, _org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
246 Ok(Vec::new())
247 }
248
249 async fn save(&self, _repo: &Repository) -> RepositoryResult<()> {
250 Err(RepositoryError::backend("save failed on purpose"))
251 }
252 }
253
254 struct Fixture {
255 orgs: InMemoryOrgRepo,
256 memberships: InMemoryMembershipRepo,
257 repos: InMemoryRepoRepo,
258 storage: InMemoryGitStorage,
259 owner: Actor,
260 member: Actor,
261 stranger: Actor,
262 handle: OrgName,
263 }
264
265 async fn fixture() -> Fixture {
266 let orgs = InMemoryOrgRepo::new();
267 let memberships = InMemoryMembershipRepo::new();
268
269 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
270 orgs.save(&org).await.expect("save org");
271
272 let owner = UserId::generate();
273 let member = UserId::generate();
274
275 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
276 memberships
277 .save(&Membership::new(
278 MembershipId::generate(),
279 org.id.clone(),
280 user.clone(),
281 role,
282 ))
283 .await
284 .expect("save membership");
285 }
286
287 Fixture {
288 orgs,
289 memberships,
290 repos: InMemoryRepoRepo::new(),
291 storage: InMemoryGitStorage::new(),
292 owner: Actor::User(owner),
293 member: Actor::User(member),
294 stranger: Actor::User(UserId::generate()),
295 handle: org.name,
296 }
297 }
298
299 fn spec(name: &str) -> NewRepo {
300 NewRepo {
301 name: name.to_owned(),
302 description: None,
303 visibility: Visibility::Public,
304 }
305 }
306
307 impl Fixture {
308 async fn create(&self, actor: &Actor, spec: &NewRepo) -> Result<Repository> {
309 create_repo(
310 actor,
311 &self.handle,
312 spec,
313 &self.orgs,
314 &self.memberships,
315 &self.repos,
316 &self.storage,
317 )
318 .await
319 }
320 }
321
322 #[tokio::test]
323 async fn the_owner_creates_a_record_and_a_bare_repo() {
324 let f = fixture().await;
325
326 let repo = f
327 .create(&f.owner, &spec("steid"))
328 .await
329 .expect("should create");
330
331 assert_eq!(repo.name.as_str(), "steid");
332 assert!(
333 f.repos
334 .find_by_org_and_name(&repo.org_id, &repo.name)
335 .await
336 .expect("lookup")
337 .is_some()
338 );
339 assert!(f.storage.contains(&f.handle, &repo.name));
340 }
341
342 #[tokio::test]
343 async fn an_anonymous_visitor_is_refused() {
344 let f = fixture().await;
345
346 let error = f
347 .create(&Actor::Anonymous, &spec("steid"))
348 .await
349 .expect_err("should refuse");
350
351 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
352 assert!(f.storage.is_empty());
353 }
354
355 #[tokio::test]
356 async fn a_signed_in_stranger_is_refused() {
357 // Signed in is not the same as allowed.
358 let f = fixture().await;
359
360 let error = f
361 .create(&f.stranger, &spec("steid"))
362 .await
363 .expect_err("should refuse");
364
365 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
366 assert!(f.storage.is_empty());
367 }
368
369 #[tokio::test]
370 async fn a_non_owner_member_is_refused() {
371 // Membership is read access, not permission to add to someone's portfolio.
372 let f = fixture().await;
373
374 let error = f
375 .create(&f.member, &spec("steid"))
376 .await
377 .expect_err("should refuse");
378
379 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
380 assert!(f.storage.is_empty());
381 }
382
383 #[tokio::test]
384 async fn an_unknown_handle_is_not_found() {
385 let f = fixture().await;
386 let missing = OrgName::new("nobody").expect("valid handle");
387
388 let error = create_repo(
389 &f.owner,
390 &missing,
391 &spec("steid"),
392 &f.orgs,
393 &f.memberships,
394 &f.repos,
395 &f.storage,
396 )
397 .await
398 .expect_err("should not find");
399
400 assert!(matches!(
401 error,
402 Error::Domain(DomainError::NotFound {
403 entity: "repository owner"
404 })
405 ));
406 }
407
408 #[tokio::test]
409 async fn an_invalid_name_writes_nothing() {
410 // Validation precedes both side effects, so a rejected name leaves no trace.
411 let f = fixture().await;
412
413 let error = f
414 .create(&f.owner, &spec("../escape"))
415 .await
416 .expect_err("should reject");
417
418 assert!(matches!(
419 error,
420 Error::Domain(DomainError::Validation { .. })
421 ));
422 assert!(f.storage.is_empty());
423 }
424
425 #[tokio::test]
426 async fn a_duplicate_name_is_refused_and_changes_nothing() {
427 let f = fixture().await;
428 let first = f
429 .create(&f.owner, &spec("steid"))
430 .await
431 .expect("should create");
432
433 let error = f
434 .create(&f.owner, &spec("steid"))
435 .await
436 .expect_err("should refuse");
437
438 assert!(matches!(
439 error,
440 Error::Domain(DomainError::AlreadyExists {
441 entity: "repository"
442 })
443 ));
444 assert_eq!(f.storage.len(), 1, "the existing repo should be untouched");
445 assert_eq!(
446 f.repos
447 .find_by_org_and_name(&first.org_id, &first.name)
448 .await
449 .expect("lookup")
450 .expect("still there")
451 .id,
452 first.id
453 );
454 }
455
456 #[tokio::test]
457 async fn a_duplicate_is_caught_case_insensitively() {
458 // The name normalises, so `Steid` and `steid` are the same repository.
459 let f = fixture().await;
460 f.create(&f.owner, &spec("steid"))
461 .await
462 .expect("should create");
463
464 let error = f
465 .create(&f.owner, &spec("Steid"))
466 .await
467 .expect_err("should refuse");
468
469 assert!(matches!(
470 error,
471 Error::Domain(DomainError::AlreadyExists {
472 entity: "repository"
473 })
474 ));
475 }
476
477 #[tokio::test]
478 async fn an_orphaned_directory_reads_as_a_taken_name() {
479 // No record, but the path is occupied — a create that died between the writes.
480 // The visitor is told the name is taken, because from outside it is.
481 let f = fixture().await;
482 let name = RepoName::new("steid").expect("valid");
483 f.storage
484 .init_bare(&f.handle, &name)
485 .await
486 .expect("orphan the directory");
487
488 let error = f
489 .create(&f.owner, &spec("steid"))
490 .await
491 .expect_err("should refuse");
492
493 assert!(matches!(
494 error,
495 Error::Domain(DomainError::AlreadyExists {
496 entity: "repository"
497 })
498 ));
499 }
500
501 #[tokio::test]
502 async fn the_name_is_normalised_in_what_comes_back() {
503 // The caller redirects using this, so it has to be the stored form.
504 let f = fixture().await;
505
506 let repo = f
507 .create(&f.owner, &spec(" MyRepo "))
508 .await
509 .expect("should create");
510
511 assert_eq!(repo.name.as_str(), "myrepo");
512 assert!(f.storage.contains(&f.handle, &repo.name));
513 }
514
515 #[tokio::test]
516 async fn visibility_and_description_are_carried_through() {
517 let f = fixture().await;
518
519 let repo = f
520 .create(
521 &f.owner,
522 &NewRepo {
523 name: "steid".to_owned(),
524 description: Some(" A gitforge. ".to_owned()),
525 visibility: Visibility::Private,
526 },
527 )
528 .await
529 .expect("should create");
530
531 assert_eq!(repo.visibility, Visibility::Private);
532 assert_eq!(repo.description.as_deref(), Some("A gitforge."));
533 }
534
535 #[tokio::test]
536 async fn a_blank_description_is_stored_as_unset() {
537 let f = fixture().await;
538
539 let repo = f
540 .create(
541 &f.owner,
542 &NewRepo {
543 name: "steid".to_owned(),
544 description: Some(" ".to_owned()),
545 visibility: Visibility::Public,
546 },
547 )
548 .await
549 .expect("should create");
550
551 assert_eq!(repo.description, None);
552 }
553
554 #[tokio::test]
555 async fn a_failed_save_removes_the_bare_repo() {
556 // The compensating transaction. Without it every failed insert leaks a
557 // directory that then blocks the name forever.
558 let f = fixture().await;
559
560 let error = create_repo(
561 &f.owner,
562 &f.handle,
563 &spec("steid"),
564 &f.orgs,
565 &f.memberships,
566 &FailingRepoRepo,
567 &f.storage,
568 )
569 .await
570 .expect_err("should fail");
571
572 assert!(matches!(error, Error::Repository(_)));
573 assert!(
574 f.storage.is_empty(),
575 "the bare repo should have been compensated away"
576 );
577 }
578
579 #[tokio::test]
580 async fn a_successful_save_keeps_the_bare_repo() {
581 // The other half of the above: compensation must not fire on the happy path.
582 let f = fixture().await;
583
584 let repo = f
585 .create(&f.owner, &spec("steid"))
586 .await
587 .expect("should create");
588
589 assert!(f.storage.contains(&f.handle, &repo.name));
590 }
591
592 #[tokio::test]
593 async fn a_compensated_name_can_be_created_again() {
594 let f = fixture().await;
595 let _ = create_repo(
596 &f.owner,
597 &f.handle,
598 &spec("steid"),
599 &f.orgs,
600 &f.memberships,
601 &FailingRepoRepo,
602 &f.storage,
603 )
604 .await;
605
606 f.create(&f.owner, &spec("steid"))
607 .await
608 .expect("the name should be free again");
609 }
610
611 /// The one test that runs the real adapter. Everything above proves the use case's
612 /// logic against a fake; this proves the record and the directory actually both
613 /// appear when it is wired to disk.
614 #[tokio::test]
615 async fn against_real_disk_storage_both_the_row_and_the_repo_appear() {
616 let f = fixture().await;
617 let dir = tempfile::TempDir::new().expect("temp dir");
618 let storage = DiskGitStorage::new(dir.path());
619
620 let repo = create_repo(
621 &f.owner,
622 &f.handle,
623 &spec("steid"),
624 &f.orgs,
625 &f.memberships,
626 &f.repos,
627 &storage,
628 )
629 .await
630 .expect("should create");
631
632 assert!(
633 f.repos
634 .find_by_id(&repo.id)
635 .await
636 .expect("lookup")
637 .is_some()
638 );
639 assert!(dir.path().join("acme").join("steid.git").is_dir());
640 }
641
642 // --- view_repo -------------------------------------------------------------
643
644 impl Fixture {
645 async fn view(&self, actor: &Actor, name: &str) -> Option<RepoView> {
646 view_repo(
647 &self.handle,
648 &RepoName::new(name).expect("valid name"),
649 actor,
650 &self.orgs,
651 &self.memberships,
652 &self.repos,
653 )
654 .await
655 .expect("lookup should not error")
656 }
657
658 async fn create_with(&self, visibility: Visibility, name: &str) -> Repository {
659 self.create(
660 &self.owner,
661 &NewRepo {
662 name: name.to_owned(),
663 description: None,
664 visibility,
665 },
666 )
667 .await
668 .expect("should create")
669 }
670 }
671
672 #[tokio::test]
673 async fn a_public_repository_is_visible_to_anyone() {
674 let f = fixture().await;
675 f.create_with(Visibility::Public, "steid").await;
676
677 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
678 assert!(
679 f.view(actor, "steid").await.is_some(),
680 "public repo should be visible to {actor:?}"
681 );
682 }
683 }
684
685 #[tokio::test]
686 async fn a_private_repository_is_absent_for_outsiders() {
687 // `None`, not an error: distinguishing "forbidden" from "missing" would confirm
688 // the repository exists and reveal its name.
689 let f = fixture().await;
690 f.create_with(Visibility::Private, "secret").await;
691
692 assert!(f.view(&Actor::Anonymous, "secret").await.is_none());
693 assert!(f.view(&f.stranger, "secret").await.is_none());
694 }
695
696 #[tokio::test]
697 async fn a_private_repository_is_visible_to_any_member() {
698 // Seeing is weaker than changing: a member who may not create repositories may
699 // still read the private ones.
700 let f = fixture().await;
701 f.create_with(Visibility::Private, "secret").await;
702
703 assert!(f.view(&f.member, "secret").await.is_some());
704 assert!(f.view(&f.owner, "secret").await.is_some());
705 }
706
707 #[tokio::test]
708 async fn viewer_is_owner_tracks_the_actor() {
709 let f = fixture().await;
710 f.create_with(Visibility::Public, "steid").await;
711
712 assert!(
713 f.view(&f.owner, "steid")
714 .await
715 .expect("visible")
716 .viewer_is_owner
717 );
718 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
719 assert!(
720 !f.view(actor, "steid")
721 .await
722 .expect("visible")
723 .viewer_is_owner,
724 "{actor:?} should not be treated as owner"
725 );
726 }
727 }
728
729 #[tokio::test]
730 async fn an_unknown_repository_is_absent() {
731 let f = fixture().await;
732 f.create_with(Visibility::Public, "steid").await;
733
734 assert!(f.view(&f.owner, "nothing-here").await.is_none());
735 }
736
737 #[tokio::test]
738 async fn an_unknown_handle_is_absent() {
739 let f = fixture().await;
740 let missing = OrgName::new("nobody").expect("valid handle");
741
742 let found = view_repo(
743 &missing,
744 &RepoName::new("steid").expect("valid"),
745 &f.owner,
746 &f.orgs,
747 &f.memberships,
748 &f.repos,
749 )
750 .await
751 .expect("lookup should not error");
752
753 assert!(found.is_none());
754 }
755
756 #[tokio::test]
757 async fn the_view_carries_what_a_page_needs() {
758 let f = fixture().await;
759 f.create(
760 &f.owner,
761 &NewRepo {
762 name: "steid".to_owned(),
763 description: Some("A gitforge.".to_owned()),
764 visibility: Visibility::Private,
765 },
766 )
767 .await
768 .expect("should create");
769
770 let view = f.view(&f.owner, "steid").await.expect("visible");
771
772 assert_eq!(view.handle.as_str(), "acme");
773 assert_eq!(view.name.as_str(), "steid");
774 assert_eq!(view.description.as_deref(), Some("A gitforge."));
775 assert_eq!(view.visibility, Visibility::Private);
776 }
777
778 #[tokio::test]
779 async fn lookup_is_case_insensitive_through_the_name_type() {
780 let f = fixture().await;
781 f.create_with(Visibility::Public, "MyRepo").await;
782
783 assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some());
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 }
934}