steid

@jamesgill /

steid/src/application/repo_tests.rs
Rust66.3 KBCode·Blame·History·Raw
1use super::*;
2use crate::{
3 domain::{
4 Issue, IssueId, IssueNumber, Membership, MembershipId, OrgId, Organization, RepoName, Role,
5 UserId,
6 repository::{RepositoryError, RepositoryResult},
7 },
8 infrastructure::{
9 git::{DiskGitStorage, InMemoryGitQuery, InMemoryGitStorage},
10 repository::{
11 InMemoryIssueRepo, InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryPullRequestRepo,
12 InMemoryRepoRepo,
13 },
14 },
15};
16
17fn at(seconds: u64) -> SystemTime {
18 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds)
19}
20
21/// When every repository these tests create is created. Stated by the caller now
22/// that `create_repo` takes a `now`, so "it was dated" is an equality rather than a
23/// window around the wall clock.
24const CREATED_AT: SystemTime = SystemTime::UNIX_EPOCH;
25
26/// A `RepoRepository` whose `save` always fails, for exercising compensation.
27///
28/// Test-local on purpose: fault injection does not belong in the shared fake, where
29/// every other test would have to know about it.
30#[derive(Debug, Default)]
31struct FailingRepoRepo;
32
33impl RepoRepository for FailingRepoRepo {
34 async fn next_number(&self, _repo_id: &RepoId) -> RepositoryResult<IssueNumber> {
35 Ok(IssueNumber::from_trusted(1))
36 }
37
38 async fn find_by_id(&self, _id: &RepoId) -> RepositoryResult<Option<Repository>> {
39 Ok(None)
40 }
41
42 async fn find_by_org_and_name(
43 &self,
44 _org_id: &OrgId,
45 _name: &RepoName,
46 ) -> RepositoryResult<Option<Repository>> {
47 Ok(None)
48 }
49
50 async fn list_by_org(&self, _org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
51 Ok(Vec::new())
52 }
53
54 async fn save(&self, _repo: &Repository) -> RepositoryResult<()> {
55 Err(RepositoryError::backend("save failed on purpose"))
56 }
57
58 async fn touch(&self, _id: &RepoId, _now: SystemTime) -> RepositoryResult<()> {
59 Ok(())
60 }
61
62 async fn fail_interrupted_imports(&self, _reason: &str) -> RepositoryResult<u64> {
63 Ok(0)
64 }
65
66 async fn count(&self) -> RepositoryResult<u64> {
67 Ok(0)
68 }
69
70 async fn delete(&self, _id: &RepoId) -> RepositoryResult<()> {
71 Ok(())
72 }
73}
74
75struct Fixture {
76 orgs: InMemoryOrgRepo,
77 memberships: InMemoryMembershipRepo,
78 repos: InMemoryRepoRepo,
79 /// Only the open count the view carries comes from here, but a page's frame reads
80 /// it on every repository page, so the fixture has one.
81 issues: InMemoryIssueRepo,
82 pulls: InMemoryPullRequestRepo,
83 storage: InMemoryGitStorage,
84 owner: Actor,
85 member: Actor,
86 stranger: Actor,
87 handle: OrgName,
88}
89
90async fn fixture() -> Fixture {
91 let orgs = InMemoryOrgRepo::new();
92 let memberships = InMemoryMembershipRepo::new();
93
94 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
95 orgs.save(&org).await.expect("save org");
96
97 let owner = UserId::generate();
98 let member = UserId::generate();
99
100 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
101 memberships
102 .save(&Membership::new(
103 MembershipId::generate(),
104 org.id.clone(),
105 user.clone(),
106 role,
107 ))
108 .await
109 .expect("save membership");
110 }
111
112 Fixture {
113 orgs,
114 memberships,
115 repos: InMemoryRepoRepo::new(),
116 issues: InMemoryIssueRepo::new(),
117 pulls: InMemoryPullRequestRepo::new(),
118 storage: InMemoryGitStorage::new(),
119 owner: Actor::User(owner),
120 member: Actor::User(member),
121 stranger: Actor::User(UserId::generate()),
122 handle: org.name,
123 }
124}
125
126fn spec(name: &str) -> NewRepo {
127 NewRepo {
128 name: name.to_owned(),
129 description: None,
130 visibility: Visibility::Public,
131 }
132}
133
134impl Fixture {
135 async fn create(&self, actor: &Actor, spec: &NewRepo) -> Result<Repository> {
136 create_repo(
137 actor,
138 &self.handle,
139 spec,
140 CREATED_AT,
141 &Stores::new(&self.orgs, &self.memberships, &self.repos),
142 &self.storage,
143 )
144 .await
145 }
146}
147
148#[tokio::test]
149async fn the_owner_creates_a_record_and_a_bare_repo() {
150 let f = fixture().await;
151
152 let repo = f
153 .create(&f.owner, &spec("steid"))
154 .await
155 .expect("should create");
156
157 assert_eq!(repo.name.as_str(), "steid");
158 assert!(
159 f.repos
160 .find_by_org_and_name(&repo.org_id, &repo.name)
161 .await
162 .expect("lookup")
163 .is_some()
164 );
165 assert!(f.storage.contains(&f.handle, &repo.name));
166}
167
168#[tokio::test]
169async fn an_anonymous_visitor_is_refused() {
170 let f = fixture().await;
171
172 let error = f
173 .create(&Actor::Anonymous, &spec("steid"))
174 .await
175 .expect_err("should refuse");
176
177 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
178 assert!(f.storage.is_empty());
179}
180
181#[tokio::test]
182async fn a_signed_in_stranger_is_refused() {
183 // Signed in is not the same as allowed.
184 let f = fixture().await;
185
186 let error = f
187 .create(&f.stranger, &spec("steid"))
188 .await
189 .expect_err("should refuse");
190
191 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
192 assert!(f.storage.is_empty());
193}
194
195#[tokio::test]
196async fn a_non_owner_member_is_refused() {
197 // Membership is read access, not permission to add to someone's portfolio.
198 let f = fixture().await;
199
200 let error = f
201 .create(&f.member, &spec("steid"))
202 .await
203 .expect_err("should refuse");
204
205 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
206 assert!(f.storage.is_empty());
207}
208
209#[tokio::test]
210async fn an_unknown_handle_is_not_found() {
211 let f = fixture().await;
212 let missing = OrgName::new("nobody").expect("valid handle");
213
214 let error = create_repo(
215 &f.owner,
216 &missing,
217 &spec("steid"),
218 CREATED_AT,
219 &Stores::new(&f.orgs, &f.memberships, &f.repos),
220 &f.storage,
221 )
222 .await
223 .expect_err("should not find");
224
225 assert!(matches!(
226 error,
227 Error::Domain(DomainError::NotFound {
228 entity: "repository owner"
229 })
230 ));
231}
232
233#[tokio::test]
234async fn an_invalid_name_writes_nothing() {
235 // Validation precedes both side effects, so a rejected name leaves no trace.
236 let f = fixture().await;
237
238 let error = f
239 .create(&f.owner, &spec("../escape"))
240 .await
241 .expect_err("should reject");
242
243 assert!(matches!(
244 error,
245 Error::Domain(DomainError::Validation { .. })
246 ));
247 assert!(f.storage.is_empty());
248}
249
250#[tokio::test]
251async fn a_duplicate_name_is_refused_and_changes_nothing() {
252 let f = fixture().await;
253 let first = f
254 .create(&f.owner, &spec("steid"))
255 .await
256 .expect("should create");
257
258 let error = f
259 .create(&f.owner, &spec("steid"))
260 .await
261 .expect_err("should refuse");
262
263 assert!(matches!(
264 error,
265 Error::Domain(DomainError::AlreadyExists {
266 entity: "repository"
267 })
268 ));
269 assert_eq!(f.storage.len(), 1, "the existing repo should be untouched");
270 assert_eq!(
271 f.repos
272 .find_by_org_and_name(&first.org_id, &first.name)
273 .await
274 .expect("lookup")
275 .expect("still there")
276 .id,
277 first.id
278 );
279}
280
281#[tokio::test]
282async fn a_duplicate_is_caught_case_insensitively() {
283 // The name normalises, so `Steid` and `steid` are the same repository.
284 let f = fixture().await;
285 f.create(&f.owner, &spec("steid"))
286 .await
287 .expect("should create");
288
289 let error = f
290 .create(&f.owner, &spec("Steid"))
291 .await
292 .expect_err("should refuse");
293
294 assert!(matches!(
295 error,
296 Error::Domain(DomainError::AlreadyExists {
297 entity: "repository"
298 })
299 ));
300}
301
302#[tokio::test]
303async fn an_orphaned_directory_reads_as_a_taken_name() {
304 // No record, but the path is occupied — a create that died between the writes.
305 // The visitor is told the name is taken, because from outside it is.
306 let f = fixture().await;
307 let name = RepoName::new("steid").expect("valid");
308 f.storage
309 .init_bare(&f.handle, &name)
310 .await
311 .expect("orphan the directory");
312
313 let error = f
314 .create(&f.owner, &spec("steid"))
315 .await
316 .expect_err("should refuse");
317
318 assert!(matches!(
319 error,
320 Error::Domain(DomainError::AlreadyExists {
321 entity: "repository"
322 })
323 ));
324}
325
326#[tokio::test]
327async fn the_name_is_normalised_in_what_comes_back() {
328 // The caller redirects using this, so it has to be the stored form.
329 let f = fixture().await;
330
331 let repo = f
332 .create(&f.owner, &spec(" MyRepo "))
333 .await
334 .expect("should create");
335
336 assert_eq!(repo.name.as_str(), "myrepo");
337 assert!(f.storage.contains(&f.handle, &repo.name));
338}
339
340#[tokio::test]
341async fn visibility_and_description_are_carried_through() {
342 let f = fixture().await;
343
344 let repo = f
345 .create(
346 &f.owner,
347 &NewRepo {
348 name: "steid".to_owned(),
349 description: Some(" A gitforge. ".to_owned()),
350 visibility: Visibility::Private,
351 },
352 )
353 .await
354 .expect("should create");
355
356 assert_eq!(repo.visibility, Visibility::Private);
357 assert_eq!(repo.description.as_deref(), Some("A gitforge."));
358}
359
360#[tokio::test]
361async fn a_blank_description_is_stored_as_unset() {
362 let f = fixture().await;
363
364 let repo = f
365 .create(
366 &f.owner,
367 &NewRepo {
368 name: "steid".to_owned(),
369 description: Some(" ".to_owned()),
370 visibility: Visibility::Public,
371 },
372 )
373 .await
374 .expect("should create");
375
376 assert_eq!(repo.description, None);
377}
378
379#[tokio::test]
380async fn a_failed_save_removes_the_bare_repo() {
381 // The compensating transaction. Without it every failed insert leaks a
382 // directory that then blocks the name forever.
383 let f = fixture().await;
384
385 let error = create_repo(
386 &f.owner,
387 &f.handle,
388 &spec("steid"),
389 CREATED_AT,
390 &Stores::new(&f.orgs, &f.memberships, &FailingRepoRepo),
391 &f.storage,
392 )
393 .await
394 .expect_err("should fail");
395
396 assert!(matches!(error, Error::Repository(_)));
397 assert!(
398 f.storage.is_empty(),
399 "the bare repo should have been compensated away"
400 );
401}
402
403#[tokio::test]
404async fn a_successful_save_keeps_the_bare_repo() {
405 // The other half of the above: compensation must not fire on the happy path.
406 let f = fixture().await;
407
408 let repo = f
409 .create(&f.owner, &spec("steid"))
410 .await
411 .expect("should create");
412
413 assert!(f.storage.contains(&f.handle, &repo.name));
414}
415
416#[tokio::test]
417async fn a_compensated_name_can_be_created_again() {
418 let f = fixture().await;
419 let _ = create_repo(
420 &f.owner,
421 &f.handle,
422 &spec("steid"),
423 CREATED_AT,
424 &Stores::new(&f.orgs, &f.memberships, &FailingRepoRepo),
425 &f.storage,
426 )
427 .await;
428
429 f.create(&f.owner, &spec("steid"))
430 .await
431 .expect("the name should be free again");
432}
433
434/// The one test that runs the real adapter. Everything above proves the use case's
435/// logic against a fake; this proves the record and the directory actually both
436/// appear when it is wired to disk.
437#[tokio::test]
438async fn against_real_disk_storage_both_the_row_and_the_repo_appear() {
439 let f = fixture().await;
440 let dir = tempfile::TempDir::new().expect("temp dir");
441 let storage = DiskGitStorage::new(dir.path());
442
443 let repo = create_repo(
444 &f.owner,
445 &f.handle,
446 &spec("steid"),
447 CREATED_AT,
448 &Stores::new(&f.orgs, &f.memberships, &f.repos),
449 &storage,
450 )
451 .await
452 .expect("should create");
453
454 assert!(
455 f.repos
456 .find_by_id(&repo.id)
457 .await
458 .expect("lookup")
459 .is_some()
460 );
461 assert!(dir.path().join("acme").join("steid.git").is_dir());
462}
463
464// --- view_repo -------------------------------------------------------------
465
466impl Fixture {
467 async fn view(&self, actor: &Actor, name: &str) -> Option<RepoView> {
468 view_repo(
469 &self.handle,
470 &RepoName::new(name).expect("valid name"),
471 actor,
472 &Stores::new(&self.orgs, &self.memberships, &self.repos),
473 &self.issues,
474 &self.pulls,
475 )
476 .await
477 .expect("lookup should not error")
478 }
479
480 /// Creates a repository and dates it, so an ordering test states its own times
481 /// rather than depending on how fast it runs.
482 async fn create_dated(&self, name: &str, seconds: u64) -> Repository {
483 let repo = self.create_with(Visibility::Public, name).await;
484 self.repos
485 .touch(&repo.id, at(seconds))
486 .await
487 .expect("touch");
488 Repository {
489 updated_at: at(seconds),
490 ..repo
491 }
492 }
493
494 async fn create_with(&self, visibility: Visibility, name: &str) -> Repository {
495 self.create(
496 &self.owner,
497 &NewRepo {
498 name: name.to_owned(),
499 description: None,
500 visibility,
501 },
502 )
503 .await
504 .expect("should create")
505 }
506}
507
508#[tokio::test]
509async fn a_public_repository_is_visible_to_anyone() {
510 let f = fixture().await;
511 f.create_with(Visibility::Public, "steid").await;
512
513 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
514 assert!(
515 f.view(actor, "steid").await.is_some(),
516 "public repo should be visible to {actor:?}"
517 );
518 }
519}
520
521#[tokio::test]
522async fn a_private_repository_is_absent_for_outsiders() {
523 // `None`, not an error: distinguishing "forbidden" from "missing" would confirm
524 // the repository exists and reveal its name.
525 let f = fixture().await;
526 f.create_with(Visibility::Private, "secret").await;
527
528 assert!(f.view(&Actor::Anonymous, "secret").await.is_none());
529 assert!(f.view(&f.stranger, "secret").await.is_none());
530}
531
532#[tokio::test]
533async fn a_private_repository_is_visible_to_any_member() {
534 // Seeing is weaker than changing: a member who may not create repositories may
535 // still read the private ones.
536 let f = fixture().await;
537 f.create_with(Visibility::Private, "secret").await;
538
539 assert!(f.view(&f.member, "secret").await.is_some());
540 assert!(f.view(&f.owner, "secret").await.is_some());
541}
542
543#[tokio::test]
544async fn viewer_is_owner_tracks_the_actor() {
545 let f = fixture().await;
546 f.create_with(Visibility::Public, "steid").await;
547
548 assert!(
549 f.view(&f.owner, "steid")
550 .await
551 .expect("visible")
552 .viewer_is_owner
553 );
554 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
555 assert!(
556 !f.view(actor, "steid")
557 .await
558 .expect("visible")
559 .viewer_is_owner,
560 "{actor:?} should not be treated as owner"
561 );
562 }
563}
564
565#[tokio::test]
566async fn an_unknown_repository_is_absent() {
567 let f = fixture().await;
568 f.create_with(Visibility::Public, "steid").await;
569
570 assert!(f.view(&f.owner, "nothing-here").await.is_none());
571}
572
573#[tokio::test]
574async fn an_unknown_handle_is_absent() {
575 let f = fixture().await;
576 let missing = OrgName::new("nobody").expect("valid handle");
577
578 let found = view_repo(
579 &missing,
580 &RepoName::new("steid").expect("valid"),
581 &f.owner,
582 &Stores::new(&f.orgs, &f.memberships, &f.repos),
583 &f.issues,
584 &f.pulls,
585 )
586 .await
587 .expect("lookup should not error");
588
589 assert!(found.is_none());
590}
591
592#[tokio::test]
593async fn the_view_carries_what_a_page_needs() {
594 let f = fixture().await;
595 f.create(
596 &f.owner,
597 &NewRepo {
598 name: "steid".to_owned(),
599 description: Some("A gitforge.".to_owned()),
600 visibility: Visibility::Private,
601 },
602 )
603 .await
604 .expect("should create");
605
606 let view = f.view(&f.owner, "steid").await.expect("visible");
607
608 assert_eq!(view.handle.as_str(), "acme");
609 assert_eq!(view.name.as_str(), "steid");
610 assert_eq!(view.description.as_deref(), Some("A gitforge."));
611 assert_eq!(view.visibility, Visibility::Private);
612 assert_eq!(view.open_issues, 0);
613}
614
615#[tokio::test]
616async fn the_view_counts_the_open_issues_for_the_tab() {
617 // On the view rather than passed by the issues pages, so the Issues tab states
618 // the same number wherever you are in the repository. Closed issues are not in
619 // it: the tab answers "is there anything outstanding".
620 let f = fixture().await;
621 let repo = f.create_with(Visibility::Public, "steid").await;
622
623 for number in 1..=3 {
624 let mut issue = Issue::new(
625 IssueId::generate(),
626 repo.id.clone(),
627 IssueNumber::from_trusted(number),
628 format!("Issue {number}"),
629 "",
630 UserId::generate(),
631 at(1_000),
632 )
633 .expect("valid issue");
634
635 if number == 3 {
636 issue.close(at(2_000));
637 }
638
639 f.issues.save(&issue).await.expect("save");
640 }
641
642 let view = f.view(&f.owner, "steid").await.expect("visible");
643
644 assert_eq!(view.open_issues, 2);
645}
646
647#[tokio::test]
648async fn lookup_is_case_insensitive_through_the_name_type() {
649 let f = fixture().await;
650 f.create_with(Visibility::Public, "MyRepo").await;
651
652 assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some());
653}
654
655// --- list_repos ------------------------------------------------------------
656
657impl Fixture {
658 async fn list(&self, actor: &Actor) -> Vec<RepoSummary> {
659 self.list_page(actor, None).await.items
660 }
661
662 async fn list_page(&self, actor: &Actor, page: Option<usize>) -> Paged<RepoSummary> {
663 list_repos(
664 &self.handle,
665 actor,
666 page,
667 &Stores::new(&self.orgs, &self.memberships, &self.repos),
668 )
669 .await
670 .expect("listing should not error")
671 .expect("the handle exists")
672 }
673
674 fn names(summaries: &[RepoSummary]) -> Vec<&str> {
675 summaries.iter().map(|repo| repo.name.as_str()).collect()
676 }
677}
678
679/// Two public and one private, dated so that recency and the alphabet disagree.
680///
681/// `zebra` is the most recent and `alpha` the oldest, so a listing that came back
682/// alphabetical would be visibly wrong rather than accidentally right.
683async fn mixed() -> Fixture {
684 let f = fixture().await;
685 f.create_dated("zebra", 3_000).await;
686 f.repos
687 .save(&Repository {
688 updated_at: at(2_000),
689 ..f.create_with(Visibility::Private, "secret").await
690 })
691 .await
692 .expect("date the private repo");
693 f.create_dated("alpha", 1_000).await;
694 f
695}
696
697#[tokio::test]
698async fn the_second_page_of_repositories_starts_where_the_first_ended() {
699 let f = fixture().await;
700
701 for n in 0..(REPO_LIMIT + 3) {
702 f.create_dated(&format!("repo-{n:03}"), 1_000 + n as u64)
703 .await;
704 }
705
706 let first = f.list_page(&f.owner, Some(1)).await;
707 let second = f.list_page(&f.owner, Some(2)).await;
708
709 assert_eq!(first.items.len(), REPO_LIMIT);
710 assert_eq!(second.items.len(), 3);
711 assert_eq!(second.offset(), REPO_LIMIT);
712 assert_eq!(second.total, REPO_LIMIT + 3);
713 assert!(
714 !first
715 .items
716 .iter()
717 .any(|repo| repo.name == second.items[0].name),
718 "the pages do not overlap"
719 );
720}
721
722#[tokio::test]
723async fn a_page_of_repositories_is_cut_after_the_visibility_filter() {
724 // Otherwise page 2 would be short by however many private repositories page 1
725 // silently counted, and the total would say how many are hidden.
726 let f = mixed().await;
727
728 let seen = f.list_page(&Actor::Anonymous, Some(1)).await;
729 let owned = f.list_page(&f.owner, Some(1)).await;
730
731 assert!(owned.total > seen.total);
732 assert_eq!(seen.items.len(), seen.total);
733}
734
735#[tokio::test]
736async fn outsiders_see_only_public_repositories() {
737 let f = mixed().await;
738
739 for actor in [&Actor::Anonymous, &f.stranger] {
740 let listed = f.list(actor).await;
741 assert_eq!(
742 Fixture::names(&listed),
743 vec!["zebra", "alpha"],
744 "{actor:?} should see only the public repositories"
745 );
746 }
747}
748
749#[tokio::test]
750async fn members_and_owners_see_private_repositories_too() {
751 let f = mixed().await;
752
753 for actor in [&f.member, &f.owner] {
754 let listed = f.list(actor).await;
755 assert_eq!(
756 Fixture::names(&listed),
757 vec!["zebra", "secret", "alpha"],
758 "{actor:?} should see everything"
759 );
760 }
761}
762
763#[tokio::test]
764async fn listings_are_ordered_by_recency_not_by_name() {
765 // Changed deliberately from alphabetical: the profile is a portfolio, and
766 // alphabetical is a filing rule that puts `dotfiles` above the thing being built.
767 let f = mixed().await;
768
769 assert_eq!(
770 Fixture::names(&f.list(&f.owner).await),
771 vec!["zebra", "secret", "alpha"]
772 );
773}
774
775#[tokio::test]
776async fn repositories_updated_in_the_same_second_are_ordered_by_name() {
777 // Every repository that predates `updated_at` shares one timestamp, so without a
778 // tiebreak a profile would reshuffle itself between page loads.
779 let f = fixture().await;
780 for name in ["zebra", "alpha", "middle"] {
781 f.create_dated(name, 1_000).await;
782 }
783
784 assert_eq!(
785 Fixture::names(&f.list(&f.owner).await),
786 vec!["alpha", "middle", "zebra"]
787 );
788}
789
790#[tokio::test]
791async fn a_new_repository_is_dated_when_it_was_created() {
792 let f = fixture().await;
793
794 let repo = f.create_with(Visibility::Public, "steid").await;
795
796 // The instant the caller stated, not one the use case went and read.
797 assert_eq!(repo.updated_at, CREATED_AT);
798 assert!(!repo.pinned, "nothing leads a profile by being created");
799}
800
801#[tokio::test]
802async fn a_viewer_who_may_see_nothing_gets_an_empty_list() {
803 // Not a count, not a hint. Either would leak that private repositories exist
804 // and how many.
805 let f = fixture().await;
806 f.create_with(Visibility::Private, "secret").await;
807 f.create_with(Visibility::Private, "other").await;
808
809 assert!(f.list(&Actor::Anonymous).await.is_empty());
810}
811
812#[tokio::test]
813async fn a_handle_with_no_repositories_lists_nothing() {
814 let f = fixture().await;
815
816 assert!(f.list(&f.owner).await.is_empty());
817}
818
819#[tokio::test]
820async fn an_unknown_handle_is_none_not_an_empty_list() {
821 // `/api` has to answer 404 for a handle that does not exist rather than `[]`.
822 let f = fixture().await;
823 let missing = OrgName::new("nobody").expect("valid handle");
824
825 let listed = list_repos(
826 &missing,
827 &f.owner,
828 None,
829 &Stores::new(&f.orgs, &f.memberships, &f.repos),
830 )
831 .await
832 .expect("listing should not error");
833
834 assert!(listed.is_none());
835}
836
837#[tokio::test]
838async fn a_summary_carries_what_a_listing_renders() {
839 let f = fixture().await;
840 f.create(
841 &f.owner,
842 &NewRepo {
843 name: "steid".to_owned(),
844 description: Some("A gitforge.".to_owned()),
845 visibility: Visibility::Private,
846 },
847 )
848 .await
849 .expect("should create");
850
851 let listed = f.list(&f.owner).await;
852 let summary = listed.first().expect("one repository");
853
854 assert_eq!(summary.name.as_str(), "steid");
855 assert_eq!(summary.description.as_deref(), Some("A gitforge."));
856 assert_eq!(summary.visibility, Visibility::Private);
857 // The profile dates each row and picks its lead out of the listing, so both
858 // travel with the summary rather than costing a second lookup.
859 assert!(!summary.pinned);
860 assert_eq!(summary.updated_at, CREATED_AT);
861}
862
863#[tokio::test]
864async fn listing_only_covers_the_handle_asked_for() {
865 let f = fixture().await;
866 f.create_with(Visibility::Public, "mine").await;
867
868 let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
869 f.orgs.save(&other).await.expect("save org");
870 f.repos
871 .save(
872 &Repository::new(
873 RepoId::generate(),
874 other.id.clone(),
875 "theirs",
876 None,
877 Visibility::Public,
878 SystemTime::now(),
879 )
880 .expect("valid repo"),
881 )
882 .await
883 .expect("save repo");
884
885 assert_eq!(
886 Fixture::names(&f.list(&Actor::Anonymous).await),
887 vec!["mine"]
888 );
889}
890// --- update_repo -----------------------------------------------------------
891
892impl Fixture {
893 async fn update(
894 &self,
895 actor: &Actor,
896 name: &str,
897 description: Option<&str>,
898 visibility: Visibility,
899 ) -> Result<Repository> {
900 update_repo(
901 actor,
902 &self.handle,
903 &RepoName::new(name).expect("valid name"),
904 &RepoEdit {
905 description: description.map(str::to_owned),
906 visibility,
907 pinned: false,
908 },
909 &Stores::new(&self.orgs, &self.memberships, &self.repos),
910 )
911 .await
912 }
913
914 async fn stored(&self, name: &str) -> Option<Repository> {
915 let org = self
916 .orgs
917 .find_by_name(&self.handle)
918 .await
919 .expect("lookup")
920 .expect("the handle exists");
921
922 self.repos
923 .find_by_org_and_name(&org.id, &RepoName::new(name).expect("valid name"))
924 .await
925 .expect("lookup")
926 }
927}
928
929#[tokio::test]
930async fn the_owner_changes_the_description_and_the_visibility() {
931 let f = fixture().await;
932 f.create_with(Visibility::Public, "steid").await;
933
934 let updated = f
935 .update(
936 &f.owner,
937 "steid",
938 Some(" A gitforge. "),
939 Visibility::Private,
940 )
941 .await
942 .expect("should update");
943
944 assert_eq!(updated.description.as_deref(), Some("A gitforge."));
945 assert_eq!(updated.visibility, Visibility::Private);
946
947 let stored = f.stored("steid").await.expect("still there");
948 assert_eq!(stored.description.as_deref(), Some("A gitforge."));
949 assert_eq!(stored.visibility, Visibility::Private);
950}
951
952#[tokio::test]
953async fn a_description_can_be_cleared() {
954 let f = fixture().await;
955 f.create(
956 &f.owner,
957 &NewRepo {
958 name: "steid".to_owned(),
959 description: Some("A gitforge.".to_owned()),
960 visibility: Visibility::Public,
961 },
962 )
963 .await
964 .expect("should create");
965
966 f.update(&f.owner, "steid", None, Visibility::Public)
967 .await
968 .expect("should update");
969
970 assert_eq!(
971 f.stored("steid").await.expect("still there").description,
972 None
973 );
974}
975
976#[tokio::test]
977async fn an_update_never_touches_the_name_or_the_directory() {
978 // Renaming is a directory move, not a column update, so it is deliberately not
979 // offered here — and an update must not disturb what is on disk.
980 let f = fixture().await;
981 let created = f.create_with(Visibility::Public, "steid").await;
982
983 let updated = f
984 .update(&f.owner, "steid", Some("changed"), Visibility::Private)
985 .await
986 .expect("should update");
987
988 assert_eq!(updated.name, created.name);
989 assert_eq!(updated.id, created.id);
990 assert!(f.storage.contains(&f.handle, &created.name));
991 assert_eq!(f.storage.len(), 1);
992}
993
994#[tokio::test]
995async fn a_member_who_is_not_the_owner_cannot_change_a_repository() {
996 // The repository is public, so the member can see it; seeing is not changing.
997 let f = fixture().await;
998 f.create_with(Visibility::Public, "steid").await;
999
1000 let error = f
1001 .update(&f.member, "steid", Some("mine now"), Visibility::Private)
1002 .await
1003 .expect_err("should refuse");
1004
1005 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
1006 assert_eq!(
1007 f.stored("steid").await.expect("untouched").visibility,
1008 Visibility::Public
1009 );
1010}
1011
1012#[tokio::test]
1013async fn a_stranger_and_an_anonymous_visitor_cannot_change_a_repository() {
1014 let f = fixture().await;
1015 f.create_with(Visibility::Public, "steid").await;
1016
1017 for actor in [&Actor::Anonymous, &f.stranger] {
1018 let error = f
1019 .update(actor, "steid", Some("mine now"), Visibility::Private)
1020 .await
1021 .expect_err("should refuse");
1022
1023 assert!(
1024 matches!(error, Error::Domain(DomainError::Forbidden)),
1025 "{actor:?} should be refused"
1026 );
1027 }
1028
1029 assert_eq!(
1030 f.stored("steid").await.expect("untouched").description,
1031 None
1032 );
1033}
1034
1035#[tokio::test]
1036async fn a_private_repository_is_not_found_rather_than_forbidden_for_an_outsider() {
1037 // The established rule: "forbidden" would confirm that a private repository by
1038 // that name exists, which is exactly what private is protecting.
1039 let f = fixture().await;
1040 f.create_with(Visibility::Private, "secret").await;
1041
1042 for actor in [&Actor::Anonymous, &f.stranger] {
1043 let error = f
1044 .update(actor, "secret", None, Visibility::Public)
1045 .await
1046 .expect_err("should refuse");
1047
1048 assert!(
1049 matches!(
1050 error,
1051 Error::Domain(DomainError::NotFound {
1052 entity: "repository"
1053 })
1054 ),
1055 "{actor:?} should be told it does not exist"
1056 );
1057 }
1058
1059 assert_eq!(
1060 f.stored("secret").await.expect("untouched").visibility,
1061 Visibility::Private
1062 );
1063}
1064
1065#[tokio::test]
1066async fn an_unknown_repository_or_handle_is_not_found() {
1067 let f = fixture().await;
1068
1069 let error = f
1070 .update(&f.owner, "nothing-here", None, Visibility::Public)
1071 .await
1072 .expect_err("should refuse");
1073
1074 assert!(matches!(
1075 error,
1076 Error::Domain(DomainError::NotFound {
1077 entity: "repository"
1078 })
1079 ));
1080
1081 let missing = OrgName::new("nobody").expect("valid handle");
1082 let error = update_repo(
1083 &f.owner,
1084 &missing,
1085 &RepoName::new("steid").expect("valid"),
1086 &RepoEdit {
1087 description: None,
1088 visibility: Visibility::Public,
1089 pinned: false,
1090 },
1091 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1092 )
1093 .await
1094 .expect_err("should refuse");
1095
1096 assert!(matches!(
1097 error,
1098 Error::Domain(DomainError::NotFound {
1099 entity: "repository"
1100 })
1101 ));
1102}
1103
1104#[tokio::test]
1105async fn an_over_long_description_is_rejected_and_changes_nothing() {
1106 let f = fixture().await;
1107 f.create_with(Visibility::Public, "steid").await;
1108 let long = "a".repeat(Repository::MAX_DESCRIPTION_LEN + 1);
1109
1110 let error = f
1111 .update(&f.owner, "steid", Some(&long), Visibility::Private)
1112 .await
1113 .expect_err("should reject");
1114
1115 assert!(matches!(
1116 error,
1117 Error::Domain(DomainError::Validation { .. })
1118 ));
1119 let stored = f.stored("steid").await.expect("untouched");
1120 assert_eq!(stored.description, None);
1121 assert_eq!(stored.visibility, Visibility::Public);
1122}
1123
1124#[tokio::test]
1125async fn making_a_public_repository_private_hides_it_from_outsiders() {
1126 // The hole this closes: someone who published by accident can un-publish.
1127 let f = fixture().await;
1128 f.create_with(Visibility::Public, "oops").await;
1129 assert!(f.view(&Actor::Anonymous, "oops").await.is_some());
1130
1131 f.update(&f.owner, "oops", None, Visibility::Private)
1132 .await
1133 .expect("should update");
1134
1135 assert!(
1136 f.view(&Actor::Anonymous, "oops").await.is_none(),
1137 "it should be absent, not merely unlinked"
1138 );
1139 assert!(f.list(&Actor::Anonymous).await.is_empty());
1140 assert!(f.view(&f.owner, "oops").await.is_some());
1141}
1142
1143#[tokio::test]
1144async fn making_a_private_repository_public_reveals_it() {
1145 let f = fixture().await;
1146 f.create_with(Visibility::Private, "secret").await;
1147
1148 f.update(&f.owner, "secret", None, Visibility::Public)
1149 .await
1150 .expect("should update");
1151
1152 assert!(f.view(&Actor::Anonymous, "secret").await.is_some());
1153 assert_eq!(
1154 Fixture::names(&f.list(&Actor::Anonymous).await),
1155 vec!["secret"]
1156 );
1157}
1158
1159// --- pinning ---------------------------------------------------------------
1160
1161impl Fixture {
1162 /// Sets the pin, leaving everything else as stored.
1163 async fn set_pin(&self, actor: &Actor, name: &str, pinned: bool) -> Result<Repository> {
1164 let existing = self.stored(name).await.expect("the repository exists");
1165
1166 update_repo(
1167 actor,
1168 &self.handle,
1169 &existing.name,
1170 &RepoEdit {
1171 description: existing.description.clone(),
1172 visibility: existing.visibility,
1173 pinned,
1174 },
1175 &Stores::new(&self.orgs, &self.memberships, &self.repos),
1176 )
1177 .await
1178 }
1179
1180 async fn pinned_names(&self) -> Vec<String> {
1181 self.list(&self.owner)
1182 .await
1183 .into_iter()
1184 .filter(|summary| summary.pinned)
1185 .map(|summary| summary.name.to_string())
1186 .collect()
1187 }
1188}
1189
1190#[tokio::test]
1191async fn the_owner_pins_a_repository() {
1192 let f = fixture().await;
1193 f.create_with(Visibility::Public, "steid").await;
1194
1195 let pinned = f
1196 .set_pin(&f.owner, "steid", true)
1197 .await
1198 .expect("should pin");
1199
1200 assert!(pinned.pinned);
1201 assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1202}
1203
1204#[tokio::test]
1205async fn pinning_a_second_repository_unpins_the_first() {
1206 // At most one lead per owner. Two would leave the profile with no rule for
1207 // choosing between them.
1208 let f = fixture().await;
1209 f.create_with(Visibility::Public, "steid").await;
1210 f.create_with(Visibility::Public, "dotfiles").await;
1211 f.set_pin(&f.owner, "steid", true)
1212 .await
1213 .expect("should pin");
1214
1215 f.set_pin(&f.owner, "dotfiles", true)
1216 .await
1217 .expect("should pin");
1218
1219 assert_eq!(f.pinned_names().await, vec!["dotfiles".to_owned()]);
1220}
1221
1222#[tokio::test]
1223async fn re_pinning_the_same_repository_leaves_it_pinned() {
1224 // The unpin sweep skips the repository being saved; getting that wrong would
1225 // make a second save of an unchanged form silently clear the pin.
1226 let f = fixture().await;
1227 f.create_with(Visibility::Public, "steid").await;
1228 f.set_pin(&f.owner, "steid", true)
1229 .await
1230 .expect("should pin");
1231
1232 f.set_pin(&f.owner, "steid", true)
1233 .await
1234 .expect("should pin");
1235
1236 assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1237}
1238
1239#[tokio::test]
1240async fn unpinning_leaves_nothing_pinned() {
1241 let f = fixture().await;
1242 f.create_with(Visibility::Public, "steid").await;
1243 f.set_pin(&f.owner, "steid", true)
1244 .await
1245 .expect("should pin");
1246
1247 f.set_pin(&f.owner, "steid", false)
1248 .await
1249 .expect("should unpin");
1250
1251 assert!(f.pinned_names().await.is_empty());
1252}
1253
1254#[tokio::test]
1255async fn pinning_only_reaches_the_owners_own_repositories() {
1256 // The sweep is scoped to the org. Another owner's lead is not this owner's to
1257 // clear.
1258 let f = fixture().await;
1259 f.create_with(Visibility::Public, "steid").await;
1260
1261 let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
1262 f.orgs.save(&other).await.expect("save org");
1263 let mut theirs = Repository::new(
1264 RepoId::generate(),
1265 other.id.clone(),
1266 "theirs",
1267 None,
1268 Visibility::Public,
1269 SystemTime::now(),
1270 )
1271 .expect("valid repo");
1272 theirs.pinned = true;
1273 f.repos.save(&theirs).await.expect("save repo");
1274
1275 f.set_pin(&f.owner, "steid", true)
1276 .await
1277 .expect("should pin");
1278
1279 assert!(
1280 f.repos
1281 .find_by_id(&theirs.id)
1282 .await
1283 .expect("lookup")
1284 .expect("still there")
1285 .pinned,
1286 "another owner's lead should be untouched"
1287 );
1288}
1289
1290#[tokio::test]
1291async fn a_member_who_is_not_the_owner_cannot_pin() {
1292 // Pinning is a mutation, and every repository mutation is owner-only.
1293 let f = fixture().await;
1294 f.create_with(Visibility::Public, "steid").await;
1295
1296 for actor in [&f.member, &f.stranger, &Actor::Anonymous] {
1297 let error = f
1298 .set_pin(actor, "steid", true)
1299 .await
1300 .expect_err("should refuse");
1301
1302 assert!(
1303 matches!(error, Error::Domain(DomainError::Forbidden)),
1304 "{actor:?} should be forbidden, got {error:?}"
1305 );
1306 }
1307
1308 assert!(f.pinned_names().await.is_empty());
1309}
1310
1311#[tokio::test]
1312async fn editing_a_repository_does_not_move_its_place_in_the_listing() {
1313 // `updated_at` means "code last landed here". Rewording a description must not
1314 // jump a dormant repository to the top of a portfolio.
1315 let f = fixture().await;
1316 let repo = f.create_dated("steid", 1_000).await;
1317
1318 let updated = f
1319 .update(&f.owner, "steid", Some("A gitforge."), Visibility::Public)
1320 .await
1321 .expect("should update");
1322
1323 assert_eq!(updated.updated_at, repo.updated_at);
1324}
1325
1326// --- delete_repo -----------------------------------------------------------
1327
1328/// Git storage whose `remove` always fails, for the best-effort path.
1329#[derive(Debug, Default)]
1330struct FailingRemoveStorage;
1331
1332impl GitStorage for FailingRemoveStorage {
1333 async fn init_bare(
1334 &self,
1335 _handle: &OrgName,
1336 _name: &RepoName,
1337 ) -> std::result::Result<(), GitStorageError> {
1338 Ok(())
1339 }
1340
1341 async fn remove(
1342 &self,
1343 _handle: &OrgName,
1344 _name: &RepoName,
1345 ) -> std::result::Result<(), GitStorageError> {
1346 Err(GitStorageError::backend("remove failed on purpose"))
1347 }
1348
1349 async fn rename(
1350 &self,
1351 _handle: &OrgName,
1352 _from: &RepoName,
1353 _to: &RepoName,
1354 ) -> std::result::Result<(), GitStorageError> {
1355 Ok(())
1356 }
1357
1358 async fn set_default_branch(
1359 &self,
1360 _handle: &OrgName,
1361 _name: &RepoName,
1362 _branch: &RefName,
1363 ) -> std::result::Result<(), GitStorageError> {
1364 Ok(())
1365 }
1366
1367 async fn enable_reflogs(&self) -> std::result::Result<u64, GitStorageError> {
1368 Ok(0)
1369 }
1370
1371 async fn list_repo_dirs(
1372 &self,
1373 ) -> std::result::Result<Vec<crate::application::port::RepoDir>, GitStorageError> {
1374 Ok(Vec::new())
1375 }
1376
1377 async fn quarantine(
1378 &self,
1379 _dir: &crate::application::port::RepoDir,
1380 _label: &str,
1381 ) -> std::result::Result<std::path::PathBuf, GitStorageError> {
1382 Err(GitStorageError::backend("quarantine failed on purpose"))
1383 }
1384
1385 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> std::path::PathBuf {
1386 std::path::PathBuf::from(format!("{handle}/{name}.git"))
1387 }
1388}
1389
1390impl Fixture {
1391 async fn delete(&self, actor: &Actor, name: &str) -> Result<()> {
1392 delete_repo(
1393 actor,
1394 &self.handle,
1395 &RepoName::new(name).expect("valid name"),
1396 &Stores::new(&self.orgs, &self.memberships, &self.repos),
1397 &self.storage,
1398 )
1399 .await
1400 }
1401}
1402
1403#[tokio::test]
1404async fn the_owner_deletes_the_record_and_the_bare_repo() {
1405 let f = fixture().await;
1406 f.create_with(Visibility::Public, "steid").await;
1407
1408 f.delete(&f.owner, "steid").await.expect("should delete");
1409
1410 assert!(f.stored("steid").await.is_none());
1411 assert!(f.storage.is_empty(), "the directory should be gone too");
1412 assert!(f.view(&f.owner, "steid").await.is_none());
1413 assert!(f.list(&f.owner).await.is_empty());
1414}
1415
1416#[tokio::test]
1417async fn a_non_owner_deletes_neither_the_record_nor_the_directory() {
1418 let f = fixture().await;
1419 let repo = f.create_with(Visibility::Public, "steid").await;
1420
1421 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
1422 let error = f.delete(actor, "steid").await.expect_err("should refuse");
1423
1424 assert!(
1425 matches!(error, Error::Domain(DomainError::Forbidden)),
1426 "{actor:?} should be refused"
1427 );
1428 }
1429
1430 assert!(f.stored("steid").await.is_some());
1431 assert!(f.storage.contains(&f.handle, &repo.name));
1432}
1433
1434#[tokio::test]
1435async fn a_private_repository_is_not_found_for_an_outsider_asking_to_delete_it() {
1436 let f = fixture().await;
1437 let repo = f.create_with(Visibility::Private, "secret").await;
1438
1439 let error = f
1440 .delete(&f.stranger, "secret")
1441 .await
1442 .expect_err("should refuse");
1443
1444 assert!(matches!(
1445 error,
1446 Error::Domain(DomainError::NotFound {
1447 entity: "repository"
1448 })
1449 ));
1450 assert!(f.stored("secret").await.is_some());
1451 assert!(f.storage.contains(&f.handle, &repo.name));
1452}
1453
1454#[tokio::test]
1455async fn deleting_a_repository_that_does_not_exist_is_not_found() {
1456 let f = fixture().await;
1457
1458 let error = f
1459 .delete(&f.owner, "nothing-here")
1460 .await
1461 .expect_err("should refuse");
1462
1463 assert!(matches!(
1464 error,
1465 Error::Domain(DomainError::NotFound {
1466 entity: "repository"
1467 })
1468 ));
1469}
1470
1471#[tokio::test]
1472async fn deleting_one_repository_leaves_the_others_alone() {
1473 let f = fixture().await;
1474 f.create_with(Visibility::Public, "keep").await;
1475 f.create_with(Visibility::Public, "drop").await;
1476
1477 f.delete(&f.owner, "drop").await.expect("should delete");
1478
1479 assert_eq!(Fixture::names(&f.list(&f.owner).await), vec!["keep"]);
1480 assert_eq!(f.storage.len(), 1);
1481}
1482
1483#[tokio::test]
1484async fn a_name_freed_by_deletion_can_be_created_again() {
1485 let f = fixture().await;
1486 f.create_with(Visibility::Public, "steid").await;
1487 f.delete(&f.owner, "steid").await.expect("should delete");
1488
1489 let recreated = f
1490 .create(&f.owner, &spec("steid"))
1491 .await
1492 .expect("the name should be free again");
1493
1494 assert!(f.storage.contains(&f.handle, &recreated.name));
1495}
1496
1497#[tokio::test]
1498async fn a_directory_that_will_not_delete_still_reports_success() {
1499 // The row goes first and is already gone; as far as Steid is concerned the
1500 // repository is deleted, and there is nothing the caller could do about the
1501 // leftover directory. The failure is logged, not returned.
1502 let f = fixture().await;
1503 f.create_with(Visibility::Public, "steid").await;
1504
1505 delete_repo(
1506 &f.owner,
1507 &f.handle,
1508 &RepoName::new("steid").expect("valid"),
1509 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1510 &FailingRemoveStorage,
1511 )
1512 .await
1513 .expect("should still report success");
1514
1515 assert!(f.stored("steid").await.is_none());
1516}
1517
1518/// The real adapter, so that "the directory is gone" is more than a fake's opinion.
1519#[tokio::test]
1520async fn against_real_disk_storage_delete_removes_the_directory() {
1521 let f = fixture().await;
1522 let dir = tempfile::TempDir::new().expect("temp dir");
1523 let storage = DiskGitStorage::new(dir.path());
1524
1525 create_repo(
1526 &f.owner,
1527 &f.handle,
1528 &spec("steid"),
1529 CREATED_AT,
1530 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1531 &storage,
1532 )
1533 .await
1534 .expect("should create");
1535 assert!(dir.path().join("acme").join("steid.git").is_dir());
1536
1537 delete_repo(
1538 &f.owner,
1539 &f.handle,
1540 &RepoName::new("steid").expect("valid"),
1541 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1542 &storage,
1543 )
1544 .await
1545 .expect("should delete");
1546
1547 assert!(!dir.path().join("acme").join("steid.git").exists());
1548 assert!(f.stored("steid").await.is_none());
1549}
1550
1551// --- rename_repo -----------------------------------------------------------
1552
1553/// An in-memory repository whose `save` fails once armed, for the compensation path.
1554///
1555/// Delegates the rest, unlike [`FailingRepoRepo`], because a rename has to *find*
1556/// the repository before it can fail to save it.
1557#[derive(Debug, Default)]
1558struct SaveFailsRepoRepo {
1559 inner: InMemoryRepoRepo,
1560 armed: std::sync::atomic::AtomicBool,
1561}
1562
1563impl RepoRepository for SaveFailsRepoRepo {
1564 async fn next_number(&self, _repo_id: &RepoId) -> RepositoryResult<IssueNumber> {
1565 Ok(IssueNumber::from_trusted(1))
1566 }
1567
1568 async fn find_by_id(&self, id: &RepoId) -> RepositoryResult<Option<Repository>> {
1569 self.inner.find_by_id(id).await
1570 }
1571
1572 async fn find_by_org_and_name(
1573 &self,
1574 org_id: &OrgId,
1575 name: &RepoName,
1576 ) -> RepositoryResult<Option<Repository>> {
1577 self.inner.find_by_org_and_name(org_id, name).await
1578 }
1579
1580 async fn list_by_org(&self, org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
1581 self.inner.list_by_org(org_id).await
1582 }
1583
1584 async fn save(&self, repo: &Repository) -> RepositoryResult<()> {
1585 if self.armed.load(std::sync::atomic::Ordering::SeqCst) {
1586 return Err(RepositoryError::backend("save failed on purpose"));
1587 }
1588 self.inner.save(repo).await
1589 }
1590
1591 async fn touch(&self, id: &RepoId, now: SystemTime) -> RepositoryResult<()> {
1592 self.inner.touch(id, now).await
1593 }
1594
1595 async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
1596 self.inner.delete(id).await
1597 }
1598
1599 async fn fail_interrupted_imports(&self, reason: &str) -> RepositoryResult<u64> {
1600 self.inner.fail_interrupted_imports(reason).await
1601 }
1602
1603 async fn count(&self) -> RepositoryResult<u64> {
1604 self.inner.count().await
1605 }
1606}
1607
1608impl Fixture {
1609 /// Puts a stored repository into the state an import leaves it in, without
1610 /// running one: `import_repo` needs a `GitImport`, and what these tests are
1611 /// about is the guard rather than the clone.
1612 async fn mark_importing(&self, name: &str) {
1613 let mut repo = self.stored(name).await.expect("the repository exists");
1614 repo.start_import(
1615 ImportSource::new("https://example.com/someone/thing.git").expect("valid source"),
1616 );
1617 self.repos.save(&repo).await.expect("save");
1618 }
1619
1620 async fn rename(&self, actor: &Actor, name: &str, to: &str) -> Result<Repository> {
1621 rename_repo(
1622 actor,
1623 &self.handle,
1624 &RepoName::new(name).expect("valid name"),
1625 to,
1626 &Stores::new(&self.orgs, &self.memberships, &self.repos),
1627 &self.storage,
1628 )
1629 .await
1630 }
1631}
1632
1633#[tokio::test]
1634async fn the_owner_renames_the_record_and_the_directory() {
1635 let f = fixture().await;
1636 f.create_with(Visibility::Public, "steid").await;
1637
1638 let renamed = f
1639 .rename(&f.owner, "steid", "forge")
1640 .await
1641 .expect("should rename");
1642
1643 assert_eq!(renamed.name.as_str(), "forge");
1644 assert!(f.stored("forge").await.is_some());
1645 assert!(f.stored("steid").await.is_none());
1646 assert!(f.storage.contains(&f.handle, &renamed.name));
1647 assert!(
1648 !f.storage
1649 .contains(&f.handle, &RepoName::from_trusted("steid"))
1650 );
1651}
1652
1653#[tokio::test]
1654async fn a_rename_keeps_everything_except_the_name() {
1655 // Including `updated_at`: renaming is not code landing, so a portfolio must not
1656 // reorder itself because somebody tidied a name.
1657 let f = fixture().await;
1658 let before = f.create_dated("steid", 1_000).await;
1659 f.set_pin(&f.owner, "steid", true)
1660 .await
1661 .expect("should pin");
1662
1663 let renamed = f
1664 .rename(&f.owner, "steid", "forge")
1665 .await
1666 .expect("should rename");
1667
1668 assert_eq!(renamed.id, before.id);
1669 assert_eq!(renamed.updated_at, at(1_000));
1670 assert_eq!(renamed.visibility, before.visibility);
1671 assert!(renamed.pinned, "a pinned repository stays the lead");
1672}
1673
1674#[tokio::test]
1675async fn renaming_normalises_the_new_name() {
1676 let f = fixture().await;
1677 f.create_with(Visibility::Public, "steid").await;
1678
1679 let renamed = f
1680 .rename(&f.owner, "steid", " MyForge ")
1681 .await
1682 .expect("should rename");
1683
1684 assert_eq!(renamed.name.as_str(), "myforge");
1685}
1686
1687#[tokio::test]
1688async fn an_importing_repository_cannot_be_renamed() {
1689 // The directory this would move is the one `git clone --mirror` is writing into,
1690 // and git holds a path rather than a row: the clone would carry on filling a
1691 // directory nothing names any more.
1692 let f = fixture().await;
1693 f.create_with(Visibility::Public, "steid").await;
1694 f.mark_importing("steid").await;
1695
1696 let error = f
1697 .rename(&f.owner, "steid", "forge")
1698 .await
1699 .expect_err("should refuse");
1700
1701 assert!(
1702 matches!(
1703 error,
1704 Error::Domain(DomainError::Unavailable {
1705 entity: "repository",
1706 ..
1707 })
1708 ),
1709 "expected an unavailable repository, got {error:?}"
1710 );
1711 assert!(f.stored("steid").await.is_some(), "the row is untouched");
1712 assert!(
1713 f.storage
1714 .contains(&f.handle, &RepoName::from_trusted("steid")),
1715 "and so is the directory"
1716 );
1717}
1718
1719#[tokio::test]
1720async fn a_settings_save_during_an_import_leaves_the_import_alone() {
1721 // The seam this pass found live. `Repository::new` resets the import fields, so a
1722 // visibility change while a clone was running ended the import as far as Steid
1723 // was concerned: the page read as an ordinary empty repository and `refuse_writes`
1724 // stopped refusing pushes into the directory git was still filling.
1725 let f = fixture().await;
1726 f.create_with(Visibility::Public, "steid").await;
1727 f.mark_importing("steid").await;
1728
1729 f.update(
1730 &f.owner,
1731 "steid",
1732 Some("Still arriving."),
1733 Visibility::Private,
1734 )
1735 .await
1736 .expect("a description and visibility save is not a write to the contents");
1737
1738 let stored = f.stored("steid").await.expect("stored");
1739
1740 assert!(stored.is_importing(), "the import is still running");
1741 assert!(stored.refuse_writes(RepoWrite::Contents).is_err());
1742 assert_eq!(
1743 stored.import_source.as_ref().map(ImportSource::as_str),
1744 Some("https://example.com/someone/thing.git")
1745 );
1746 assert_eq!(stored.visibility, Visibility::Private);
1747}
1748
1749#[tokio::test]
1750async fn a_rename_keeps_where_the_repository_came_from() {
1751 // Provenance outlives the import state, so it has to outlive a rename too.
1752 let f = fixture().await;
1753 f.create_with(Visibility::Public, "steid").await;
1754 f.mark_importing("steid").await;
1755
1756 let mut landed = f.stored("steid").await.expect("stored");
1757 landed.finish_import();
1758 f.repos.save(&landed).await.expect("save");
1759
1760 let renamed = f
1761 .rename(&f.owner, "steid", "forge")
1762 .await
1763 .expect("should rename");
1764
1765 assert!(!renamed.is_importing());
1766 assert_eq!(
1767 renamed.import_source.as_ref().map(ImportSource::as_str),
1768 Some("https://example.com/someone/thing.git")
1769 );
1770}
1771
1772#[tokio::test]
1773async fn an_archived_repository_can_still_be_renamed() {
1774 // Archived is "finished", not "frozen". Pinned here because the guard is the one
1775 // thing between a rename and an archived repository.
1776 let f = fixture().await;
1777 f.create_with(Visibility::Public, "steid").await;
1778 f.set_archived(&f.owner, "steid", true)
1779 .await
1780 .expect("should archive");
1781
1782 let renamed = f
1783 .rename(&f.owner, "steid", "forge")
1784 .await
1785 .expect("should rename");
1786
1787 assert_eq!(renamed.name.as_str(), "forge");
1788 assert!(renamed.archived);
1789}
1790
1791#[tokio::test]
1792async fn renaming_to_the_same_name_changes_nothing() {
1793 // The name normalises, so `Steid` submitted for `steid` is a confirmation, not
1794 // a collision with itself.
1795 let f = fixture().await;
1796 f.create_with(Visibility::Public, "steid").await;
1797
1798 let renamed = f
1799 .rename(&f.owner, "steid", "Steid")
1800 .await
1801 .expect("should be a no-op");
1802
1803 assert_eq!(renamed.name.as_str(), "steid");
1804 assert!(f.storage.contains(&f.handle, &renamed.name));
1805}
1806
1807#[tokio::test]
1808async fn renaming_onto_an_existing_repository_is_refused_and_moves_nothing() {
1809 let f = fixture().await;
1810 f.create_with(Visibility::Public, "steid").await;
1811 f.create_with(Visibility::Public, "forge").await;
1812
1813 let error = f
1814 .rename(&f.owner, "steid", "forge")
1815 .await
1816 .expect_err("should refuse");
1817
1818 assert!(matches!(
1819 error,
1820 Error::Domain(DomainError::AlreadyExists { .. })
1821 ));
1822 assert!(f.stored("steid").await.is_some());
1823 assert!(
1824 f.storage
1825 .contains(&f.handle, &RepoName::from_trusted("steid"))
1826 );
1827}
1828
1829#[tokio::test]
1830async fn an_invalid_new_name_is_rejected_before_anything_moves() {
1831 let f = fixture().await;
1832 f.create_with(Visibility::Public, "steid").await;
1833
1834 for bad in ["../escape", "", "new", "forge.git"] {
1835 let error = f
1836 .rename(&f.owner, "steid", bad)
1837 .await
1838 .expect_err("should reject");
1839
1840 assert!(
1841 matches!(error, Error::Domain(DomainError::Validation { .. })),
1842 "expected {bad:?} to be rejected"
1843 );
1844 }
1845
1846 assert!(f.stored("steid").await.is_some());
1847 assert!(
1848 f.storage
1849 .contains(&f.handle, &RepoName::from_trusted("steid"))
1850 );
1851}
1852
1853#[tokio::test]
1854async fn only_the_owner_can_rename() {
1855 let f = fixture().await;
1856 f.create_with(Visibility::Public, "steid").await;
1857
1858 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
1859 let error = f
1860 .rename(actor, "steid", "forge")
1861 .await
1862 .expect_err("should refuse");
1863
1864 assert!(
1865 matches!(error, Error::Domain(DomainError::Forbidden)),
1866 "{actor:?} should be forbidden, got {error:?}"
1867 );
1868 }
1869
1870 assert!(f.stored("steid").await.is_some());
1871}
1872
1873#[tokio::test]
1874async fn a_private_repository_is_not_found_rather_than_forbidden_for_an_outsider_renaming_it() {
1875 let f = fixture().await;
1876 f.create_with(Visibility::Private, "secret").await;
1877
1878 for actor in [&Actor::Anonymous, &f.stranger] {
1879 let error = f
1880 .rename(actor, "secret", "forge")
1881 .await
1882 .expect_err("should refuse");
1883
1884 assert!(
1885 matches!(error, Error::Domain(DomainError::NotFound { .. })),
1886 "{actor:?} should see a 404, got {error:?}"
1887 );
1888 }
1889}
1890
1891#[tokio::test]
1892async fn a_failed_record_write_moves_the_directory_back() {
1893 // The compensation. Without it the repository is on disk under a name no row
1894 // names, and every page 404s.
1895 let f = fixture().await;
1896 let repos = SaveFailsRepoRepo::default();
1897 let storage = InMemoryGitStorage::new();
1898
1899 create_repo(
1900 &f.owner,
1901 &f.handle,
1902 &spec("steid"),
1903 CREATED_AT,
1904 &Stores::new(&f.orgs, &f.memberships, &repos),
1905 &storage,
1906 )
1907 .await
1908 .expect("should create");
1909
1910 repos.armed.store(true, std::sync::atomic::Ordering::SeqCst);
1911
1912 let error = rename_repo(
1913 &f.owner,
1914 &f.handle,
1915 &RepoName::new("steid").expect("valid"),
1916 "forge",
1917 &Stores::new(&f.orgs, &f.memberships, &repos),
1918 &storage,
1919 )
1920 .await
1921 .expect_err("the save should fail");
1922
1923 assert!(matches!(error, Error::Repository(_)));
1924 assert!(
1925 storage.contains(&f.handle, &RepoName::from_trusted("steid")),
1926 "the directory should be back where the row still says it is"
1927 );
1928 assert!(!storage.contains(&f.handle, &RepoName::from_trusted("forge")));
1929}
1930
1931#[tokio::test]
1932async fn a_name_freed_by_a_rename_can_be_used_again() {
1933 let f = fixture().await;
1934 f.create_with(Visibility::Public, "steid").await;
1935
1936 f.rename(&f.owner, "steid", "forge")
1937 .await
1938 .expect("should rename");
1939
1940 f.create(&f.owner, &spec("steid"))
1941 .await
1942 .expect("the old name is free again");
1943}
1944
1945#[tokio::test]
1946async fn against_real_disk_storage_rename_moves_the_directory() {
1947 let f = fixture().await;
1948 let dir = tempfile::tempdir().expect("tempdir");
1949 let storage = DiskGitStorage::new(dir.path());
1950
1951 create_repo(
1952 &f.owner,
1953 &f.handle,
1954 &spec("steid"),
1955 CREATED_AT,
1956 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1957 &storage,
1958 )
1959 .await
1960 .expect("should create");
1961
1962 rename_repo(
1963 &f.owner,
1964 &f.handle,
1965 &RepoName::new("steid").expect("valid"),
1966 "forge",
1967 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1968 &storage,
1969 )
1970 .await
1971 .expect("should rename");
1972
1973 assert!(
1974 dir.path()
1975 .join("acme")
1976 .join("forge.git")
1977 .join("HEAD")
1978 .is_file()
1979 );
1980 assert!(!dir.path().join("acme").join("steid.git").exists());
1981}
1982
1983// --- set_default_branch ------------------------------------------------------
1984
1985impl Fixture {
1986 async fn set_head(
1987 &self,
1988 actor: &Actor,
1989 name: &str,
1990 branch: &str,
1991 queries: &InMemoryGitQuery,
1992 ) -> Result<RefName> {
1993 set_default_branch(
1994 actor,
1995 &self.handle,
1996 &RepoName::new(name).expect("valid name"),
1997 branch,
1998 &Stores::new(&self.orgs, &self.memberships, &self.repos),
1999 queries,
2000 &self.storage,
2001 )
2002 .await
2003 }
2004}
2005
2006/// A repository with two branches, `main` being the default.
2007fn two_branches() -> InMemoryGitQuery {
2008 InMemoryGitQuery::new()
2009 .with_branch_row("main", true, at(2_000))
2010 .with_branch_row("develop", false, at(3_000))
2011}
2012
2013#[tokio::test]
2014async fn the_owner_points_head_at_another_branch() {
2015 let f = fixture().await;
2016 f.create_with(Visibility::Public, "steid").await;
2017
2018 let branch = f
2019 .set_head(&f.owner, "steid", "develop", &two_branches())
2020 .await
2021 .expect("should set");
2022
2023 assert_eq!(branch.as_str(), "develop");
2024 assert_eq!(
2025 f.storage.head(&f.handle, &RepoName::from_trusted("steid")),
2026 Some(RefName::from_trusted("develop"))
2027 );
2028}
2029
2030#[tokio::test]
2031async fn a_branch_the_repository_does_not_have_is_not_found() {
2032 // `symbolic-ref` would accept it and leave every page saying "nothing pushed
2033 // yet", which is why this is checked rather than trusted.
2034 let f = fixture().await;
2035 f.create_with(Visibility::Public, "steid").await;
2036
2037 let error = f
2038 .set_head(&f.owner, "steid", "nothing-here", &two_branches())
2039 .await
2040 .expect_err("should refuse");
2041
2042 assert!(matches!(
2043 error,
2044 Error::Domain(DomainError::NotFound { entity: "branch" })
2045 ));
2046 assert!(
2047 f.storage
2048 .head(&f.handle, &RepoName::from_trusted("steid"))
2049 .is_none()
2050 );
2051}
2052
2053#[tokio::test]
2054async fn a_tag_is_not_a_branch() {
2055 // `branches()` is the list checked against, so a tag of the same shape cannot
2056 // be smuggled into `refs/heads/`.
2057 let f = fixture().await;
2058 f.create_with(Visibility::Public, "steid").await;
2059 let queries = two_branches().with_tag_row("v0.1.0", true, at(4_000));
2060
2061 let error = f
2062 .set_head(&f.owner, "steid", "v0.1.0", &queries)
2063 .await
2064 .expect_err("should refuse");
2065
2066 assert!(matches!(
2067 error,
2068 Error::Domain(DomainError::NotFound { entity: "branch" })
2069 ));
2070}
2071
2072#[tokio::test]
2073async fn an_empty_repository_has_no_branch_to_choose() {
2074 let f = fixture().await;
2075 f.create_with(Visibility::Public, "steid").await;
2076
2077 let error = f
2078 .set_head(&f.owner, "steid", "main", &InMemoryGitQuery::empty())
2079 .await
2080 .expect_err("should refuse");
2081
2082 assert!(matches!(
2083 error,
2084 Error::Domain(DomainError::NotFound { entity: "branch" })
2085 ));
2086}
2087
2088#[tokio::test]
2089async fn a_malformed_branch_name_never_reaches_git() {
2090 let f = fixture().await;
2091 f.create_with(Visibility::Public, "steid").await;
2092
2093 for bad in ["-flag", "main..develop", "with space", ""] {
2094 let error = f
2095 .set_head(&f.owner, "steid", bad, &two_branches())
2096 .await
2097 .expect_err("should reject");
2098
2099 assert!(
2100 matches!(error, Error::Domain(DomainError::Validation { .. })),
2101 "expected {bad:?} to be rejected, got {error:?}"
2102 );
2103 }
2104
2105 assert!(
2106 f.storage
2107 .head(&f.handle, &RepoName::from_trusted("steid"))
2108 .is_none()
2109 );
2110}
2111
2112#[tokio::test]
2113async fn only_the_owner_can_move_head() {
2114 let f = fixture().await;
2115 f.create_with(Visibility::Public, "steid").await;
2116
2117 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
2118 let error = f
2119 .set_head(actor, "steid", "develop", &two_branches())
2120 .await
2121 .expect_err("should refuse");
2122
2123 assert!(
2124 matches!(error, Error::Domain(DomainError::Forbidden)),
2125 "{actor:?} should be forbidden, got {error:?}"
2126 );
2127 }
2128
2129 assert!(
2130 f.storage
2131 .head(&f.handle, &RepoName::from_trusted("steid"))
2132 .is_none()
2133 );
2134}
2135
2136#[tokio::test]
2137async fn a_private_repository_is_absent_rather_than_forbidden_when_an_outsider_moves_head() {
2138 let f = fixture().await;
2139 f.create_with(Visibility::Private, "secret").await;
2140
2141 for actor in [&Actor::Anonymous, &f.stranger] {
2142 let error = f
2143 .set_head(actor, "secret", "develop", &two_branches())
2144 .await
2145 .expect_err("should refuse");
2146
2147 assert!(
2148 matches!(error, Error::Domain(DomainError::NotFound { .. })),
2149 "{actor:?} should see a 404, got {error:?}"
2150 );
2151 }
2152}
2153
2154// --- archiving ---------------------------------------------------------------
2155
2156impl Fixture {
2157 async fn set_archived(&self, actor: &Actor, name: &str, archived: bool) -> Result<Repository> {
2158 set_repo_archived(
2159 actor,
2160 &self.handle,
2161 &RepoName::new(name).expect("valid name"),
2162 archived,
2163 &Stores::new(&self.orgs, &self.memberships, &self.repos),
2164 )
2165 .await
2166 }
2167}
2168
2169#[tokio::test]
2170async fn the_owner_archives_and_unarchives() {
2171 let f = fixture().await;
2172 f.create_with(Visibility::Public, "steid").await;
2173
2174 assert!(
2175 f.set_archived(&f.owner, "steid", true)
2176 .await
2177 .expect("should archive")
2178 .archived
2179 );
2180 assert!(f.stored("steid").await.expect("stored").archived);
2181
2182 assert!(
2183 !f.set_archived(&f.owner, "steid", false)
2184 .await
2185 .expect("should unarchive")
2186 .archived
2187 );
2188 assert!(!f.stored("steid").await.expect("stored").archived);
2189}
2190
2191#[tokio::test]
2192async fn archiving_twice_is_not_an_error() {
2193 // What a double-clicked button does.
2194 let f = fixture().await;
2195 f.create_with(Visibility::Public, "steid").await;
2196
2197 f.set_archived(&f.owner, "steid", true)
2198 .await
2199 .expect("should archive");
2200 f.set_archived(&f.owner, "steid", true)
2201 .await
2202 .expect("should still be fine");
2203
2204 assert!(f.stored("steid").await.expect("stored").archived);
2205}
2206
2207#[tokio::test]
2208async fn an_importing_repository_cannot_be_archived() {
2209 // "Finished" is not a thing to say about a repository whose contents have not
2210 // arrived. The way out of a stuck import is Delete, which the panel offers.
2211 let f = fixture().await;
2212 f.create_with(Visibility::Public, "steid").await;
2213 f.mark_importing("steid").await;
2214
2215 let error = f
2216 .set_archived(&f.owner, "steid", true)
2217 .await
2218 .expect_err("should refuse");
2219
2220 assert!(
2221 matches!(
2222 error,
2223 Error::Domain(DomainError::Unavailable {
2224 entity: "repository",
2225 ..
2226 })
2227 ),
2228 "expected an unavailable repository, got {error:?}"
2229 );
2230 assert!(!f.stored("steid").await.expect("stored").archived);
2231}
2232
2233#[tokio::test]
2234async fn archiving_does_not_move_a_repository_up_the_listing() {
2235 // `updated_at` means "code last landed here". Declaring something finished is
2236 // not work on it, and must not float it to the top of a portfolio.
2237 let f = fixture().await;
2238 f.create_dated("steid", 1_000).await;
2239
2240 let archived = f
2241 .set_archived(&f.owner, "steid", true)
2242 .await
2243 .expect("should archive");
2244
2245 assert_eq!(archived.updated_at, at(1_000));
2246}
2247
2248#[tokio::test]
2249async fn archiving_is_orthogonal_to_visibility_and_pinning() {
2250 // Archived says "finished", not "hidden": a private pinned repository keeps both
2251 // when it is archived, and stays visible to the people who could already see it.
2252 let f = fixture().await;
2253 f.create_with(Visibility::Private, "secret").await;
2254 f.set_pin(&f.owner, "secret", true)
2255 .await
2256 .expect("should pin");
2257
2258 let archived = f
2259 .set_archived(&f.owner, "secret", true)
2260 .await
2261 .expect("should archive");
2262
2263 assert_eq!(archived.visibility, Visibility::Private);
2264 assert!(archived.pinned);
2265 assert!(
2266 f.view(&f.member, "secret").await.is_some(),
2267 "a member still sees an archived private repository"
2268 );
2269}
2270
2271#[tokio::test]
2272async fn editing_or_renaming_an_archived_repository_leaves_it_archived() {
2273 // The description form does not carry this field, so it must not clear it.
2274 let f = fixture().await;
2275 f.create_with(Visibility::Public, "steid").await;
2276 f.set_archived(&f.owner, "steid", true)
2277 .await
2278 .expect("should archive");
2279
2280 f.update(&f.owner, "steid", Some("Finished."), Visibility::Public)
2281 .await
2282 .expect("should update");
2283 assert!(f.stored("steid").await.expect("stored").archived);
2284
2285 let renamed = f
2286 .rename(&f.owner, "steid", "forge")
2287 .await
2288 .expect("should rename");
2289 assert!(renamed.archived);
2290}
2291
2292#[tokio::test]
2293async fn only_the_owner_can_archive() {
2294 let f = fixture().await;
2295 f.create_with(Visibility::Public, "steid").await;
2296
2297 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
2298 let error = f
2299 .set_archived(actor, "steid", true)
2300 .await
2301 .expect_err("should refuse");
2302
2303 assert!(
2304 matches!(error, Error::Domain(DomainError::Forbidden)),
2305 "{actor:?} should be forbidden, got {error:?}"
2306 );
2307 }
2308
2309 assert!(!f.stored("steid").await.expect("stored").archived);
2310}
2311
2312#[tokio::test]
2313async fn a_private_repository_is_absent_rather_than_forbidden_when_an_outsider_archives_it() {
2314 let f = fixture().await;
2315 f.create_with(Visibility::Private, "secret").await;
2316
2317 for actor in [&Actor::Anonymous, &f.stranger] {
2318 let error = f
2319 .set_archived(actor, "secret", true)
2320 .await
2321 .expect_err("should refuse");
2322
2323 assert!(
2324 matches!(error, Error::Domain(DomainError::NotFound { .. })),
2325 "{actor:?} should see a 404, got {error:?}"
2326 );
2327 }
2328}
2329
2330#[tokio::test]
2331async fn a_view_and_a_listing_both_report_archived() {
2332 // Both carry it because both render the badge, and a page that had to read the
2333 // row again per repository would cost a query per row.
2334 let f = fixture().await;
2335 f.create_with(Visibility::Public, "steid").await;
2336 f.set_archived(&f.owner, "steid", true)
2337 .await
2338 .expect("should archive");
2339
2340 assert!(
2341 f.view(&Actor::Anonymous, "steid")
2342 .await
2343 .expect("visible")
2344 .archived
2345 );
2346 assert!(
2347 f.list(&Actor::Anonymous)
2348 .await
2349 .iter()
2350 .any(|repo| repo.name.as_str() == "steid" && repo.archived)
2351 );
2352}