@jpgilldev / steid

58.6 KBRaw
1use std::time::SystemTime;
2
3use crate::domain::{
4 Actor, DomainError, OrgName, RepoId, RepoName, Repository, Visibility,
5 repository::{MembershipRepository, OrgRepository, RepoRepository},
6};
7
8use super::{
9 authz::{is_org_member, is_org_owner},
10 error::{Error, Result},
11 port::{GitStorage, GitStorageError},
12};
13
14/// The repository a visitor is asking to create.
15///
16/// Raw input: `name` is whatever was typed, and normalising it is
17/// [`Repository::new`]'s job, not the caller's.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct NewRepo {
20 pub name: String,
21 pub description: Option<String>,
22 pub visibility: Visibility,
23}
24
25/// Creates a repository as a record and as a bare repo on disk.
26///
27/// Owner only. A member's read access is not permission to add to someone's portfolio.
28///
29/// Returns the created [`Repository`] rather than `()` because the name normalises on
30/// the way in — someone who typed `MyRepo` has to be redirected to `myrepo`, and only
31/// the returned value knows that.
32///
33/// **The two writes cannot share a transaction.** The bare repo is created first and
34/// removed again if the record fails to save, per
35/// `plans/architecture.md#db-plus-filesystem-writes`. If the process dies between them
36/// the directory is orphaned; that hole is known and unclosed.
37pub async fn create_repo(
38 actor: &Actor,
39 handle: &OrgName,
40 spec: &NewRepo,
41 orgs: &impl OrgRepository,
42 memberships: &impl MembershipRepository,
43 repos: &impl RepoRepository,
44 storage: &impl GitStorage,
45) -> Result<Repository> {
46 let Some(org) = orgs.find_by_name(handle).await? else {
47 return Err(DomainError::NotFound {
48 entity: "repository owner",
49 }
50 .into());
51 };
52
53 if !is_org_owner(&org, actor, memberships).await? {
54 return Err(DomainError::Forbidden.into());
55 }
56
57 // Validated before anything is written, so a bad name leaves neither a row nor a
58 // directory behind.
59 let repo = Repository::new(
60 RepoId::generate(),
61 org.id.clone(),
62 spec.name.clone(),
63 spec.description.clone(),
64 spec.visibility,
65 // Read here rather than taken as an argument, unlike `touch`: nothing needs to
66 // create a repository *as of* a stated time, and a parameter no caller ever
67 // varies is a parameter every caller has to think about. Revisit if a clock port
68 // appears.
69 SystemTime::now(),
70 )?;
71
72 if repos
73 .find_by_org_and_name(&org.id, &repo.name)
74 .await?
75 .is_some()
76 {
77 return Err(taken());
78 }
79
80 storage
81 .init_bare(handle, &repo.name)
82 .await
83 .map_err(|error| {
84 match error {
85 // No record, but something is already on disk: an orphan from a create that
86 // died between the two writes. The name really is unavailable, so this is
87 // what the visitor is told — at the cost of an orphan being
88 // indistinguishable from a duplicate from the outside. The durable fix is
89 // the reconciliation sweep noted in architecture.md.
90 GitStorageError::AlreadyExists => taken(),
91 other => Error::GitStorage(other),
92 }
93 })?;
94
95 if let Err(error) = repos.save(&repo).await {
96 // Compensation. Safe to delete by path because `init_bare` just proved nothing
97 // was there — this can never remove a repository that won a race, because the
98 // loser of that race never gets past `init_bare`.
99 //
100 // Best effort: if the removal also fails the caller still needs the error that
101 // started this, and an orphaned directory is the documented failure mode.
102 let _ = storage.remove(handle, &repo.name).await;
103 return Err(error.into());
104 }
105
106 Ok(repo)
107}
108
109/// A repository as a viewer is allowed to see it.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct RepoView {
112 /// The owning handle, carried so a page can build links without a second lookup.
113 pub handle: OrgName,
114 pub name: RepoName,
115 pub description: Option<String>,
116 pub visibility: Visibility,
117 /// When code last landed here. The repository page dates itself from this rather
118 /// than asking git, which costs a fork.
119 pub updated_at: SystemTime,
120 /// Whether this repository leads the owner's profile. Here as well as on
121 /// [`RepoSummary`] because the settings page renders the pin control from this view
122 /// and would otherwise have to read the row a second time to know the box's state.
123 pub pinned: bool,
124 /// Whether the viewer may change this repository. Decided here so a page and
125 /// `/api` cannot disagree about who sees a management control.
126 pub viewer_is_owner: bool,
127}
128
129/// Resolves a handle and name into a repository the viewer may see.
130///
131/// `Ok(None)` covers **both** "no such repository" and "not allowed to see it", and
132/// the caller must render them identically. Distinguishing them would confirm that a
133/// private repository exists and reveal its name, which is the thing being protected —
134/// a private repo has to be absent, not merely unlinked.
135///
136/// Private repositories are visible to any member of the owning organisation, not only
137/// its owner: seeing is weaker than changing.
138pub async fn view_repo(
139 handle: &OrgName,
140 name: &RepoName,
141 actor: &Actor,
142 orgs: &impl OrgRepository,
143 memberships: &impl MembershipRepository,
144 repos: &impl RepoRepository,
145) -> Result<Option<RepoView>> {
146 let Some(org) = orgs.find_by_name(handle).await? else {
147 return Ok(None);
148 };
149
150 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
151 return Ok(None);
152 };
153
154 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
155 return Ok(None);
156 }
157
158 Ok(Some(RepoView {
159 handle: org.name.clone(),
160 name: repo.name,
161 description: repo.description,
162 visibility: repo.visibility,
163 updated_at: repo.updated_at,
164 pinned: repo.pinned,
165 viewer_is_owner: is_org_owner(&org, actor, memberships).await?,
166 }))
167}
168
169/// A repository as it appears in a listing.
170///
171/// Leaner than [`RepoView`] on purpose: the owning handle and whether the viewer owns
172/// it are constant across a listing and already known to whatever is rendering it.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct RepoSummary {
175 pub name: RepoName,
176 pub description: Option<String>,
177 pub visibility: Visibility,
178 pub updated_at: SystemTime,
179 /// Whether this is the owner's lead repository. Carried in the listing because the
180 /// profile picks the lead *out of* the listing rather than fetching it separately —
181 /// one query, and no chance of the lead and the list disagreeing.
182 pub pinned: bool,
183}
184
185/// Every repository under a handle that the viewer is allowed to see, most recently
186/// updated first — the order [`RepoRepository::list_by_org`] defines.
187///
188/// `Ok(None)` means no such handle — distinct from `Ok(Some(vec![]))`, which means the
189/// handle exists and the viewer can see nothing under it. A caller serving `/api` needs
190/// that difference to answer 404 rather than an empty list.
191///
192/// **The filtering happens here, not in the port.** `list_by_org` deliberately returns
193/// everything, so that the page and `/api` cannot end up applying different rules.
194/// A viewer who may see nothing gets an empty list, never a count or a hint — that
195/// would leak both the existence and the number of private repositories.
196pub async fn list_repos(
197 handle: &OrgName,
198 actor: &Actor,
199 orgs: &impl OrgRepository,
200 memberships: &impl MembershipRepository,
201 repos: &impl RepoRepository,
202) -> Result<Option<Vec<RepoSummary>>> {
203 let Some(org) = orgs.find_by_name(handle).await? else {
204 return Ok(None);
205 };
206
207 // Resolved once rather than per row: membership cannot change mid-listing, and
208 // asking per repository would be a query per repository.
209 let is_member = is_org_member(&org, actor, memberships).await?;
210
211 Ok(Some(
212 repos
213 .list_by_org(&org.id)
214 .await?
215 .into_iter()
216 .filter(|repo| repo.visibility.is_public() || is_member)
217 .map(|repo| RepoSummary {
218 name: repo.name,
219 description: repo.description,
220 visibility: repo.visibility,
221 updated_at: repo.updated_at,
222 pinned: repo.pinned,
223 })
224 .collect(),
225 ))
226}
227
228/// Resolves a repository the actor is allowed to **change**.
229///
230/// Two different answers, on purpose, and the split matters:
231///
232/// - A repository the actor may not *see* is [`DomainError::NotFound`], exactly as
233/// [`view_repo`] returns `None` for it. Saying "forbidden" instead would confirm that
234/// a private repository by that name exists, which is the thing being protected.
235/// - A repository the actor *can* see but does not own is [`DomainError::Forbidden`],
236/// matching [`create_repo`]: the resource is public anyway, so pretending it is
237/// missing would be theatre.
238///
239/// A caller that wants to collapse both into a 404 — the settings page does — can; a
240/// caller that wants to explain the difference has the information to.
241async fn changeable_repo(
242 actor: &Actor,
243 handle: &OrgName,
244 name: &RepoName,
245 orgs: &impl OrgRepository,
246 memberships: &impl MembershipRepository,
247 repos: &impl RepoRepository,
248) -> Result<Repository> {
249 let missing = || {
250 Error::Domain(DomainError::NotFound {
251 entity: "repository",
252 })
253 };
254
255 let Some(org) = orgs.find_by_name(handle).await? else {
256 return Err(missing());
257 };
258
259 let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
260 return Err(missing());
261 };
262
263 if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
264 return Err(missing());
265 }
266
267 if !is_org_owner(&org, actor, memberships).await? {
268 return Err(DomainError::Forbidden.into());
269 }
270
271 Ok(repo)
272}
273
274/// The changes an owner is asking to make to a repository.
275///
276/// Grouped like [`NewRepo`] rather than passed loose, so that adding a settable field
277/// later does not change every call site — and so the argument list stays readable.
278///
279/// **No `name`.** See [`update_repo`].
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct RepoEdit {
282 pub description: Option<String>,
283 pub visibility: Visibility,
284 /// Whether this repository should lead the owner's profile. Setting it unpins
285 /// whichever repository held the spot — see [`update_repo`].
286 pub pinned: bool,
287}
288
289/// Changes a repository's description and visibility.
290///
291/// Owner only. Seeing a private repository is not permission to change it.
292///
293/// **The name is deliberately not changeable here.** A rename is not a column update:
294/// the bare repo lives at `{data_dir}/{handle}/{name}.git`, so renaming means moving a
295/// directory while clones, pushes and in-flight requests point at the old path, and it
296/// needs its own use case with its own answer for the two-writes problem in
297/// `plans/architecture.md#db-plus-filesystem-writes`. Half-doing it — updating the row
298/// and leaving the directory — would break every existing clone silently.
299///
300/// Returns the saved [`Repository`] so a caller can re-render from what was actually
301/// stored rather than from what was submitted; the description normalises on the way in.
302pub async fn update_repo(
303 actor: &Actor,
304 handle: &OrgName,
305 name: &RepoName,
306 edit: &RepoEdit,
307 orgs: &impl OrgRepository,
308 memberships: &impl MembershipRepository,
309 repos: &impl RepoRepository,
310) -> Result<Repository> {
311 let existing = changeable_repo(actor, handle, name, orgs, memberships, repos).await?;
312
313 // Rebuilt through `new` rather than assigned field by field, so the description
314 // length rule lives in exactly one place. The stored name goes back through
315 // validation as a side effect — acceptable because it was validated on the way in
316 // and has not changed, and the alternative is a second copy of the rule here.
317 let mut updated = Repository::new(
318 existing.id,
319 existing.org_id.clone(),
320 existing.name.as_str(),
321 edit.description.clone(),
322 edit.visibility,
323 // The existing timestamp, not the current time. `updated_at` means "code last
324 // landed here" and orders a portfolio by what is being worked on; rewording a
325 // description is not work on the repository and must not jump it to the top.
326 existing.updated_at,
327 )?;
328 updated.pinned = edit.pinned;
329
330 // **At most one pinned repository per owner**, enforced here rather than in storage.
331 // The rule is not "refuse a second pin" but "pinning this unpins that", and a
332 // constraint — a partial unique index would express the shape exactly — can only
333 // refuse. Enforcing it in the use case also keeps the two `RepoRepository`
334 // implementations from having to agree on a behaviour neither of them is asked for.
335 //
336 // The two writes are not one transaction, so a crash between them can leave nothing
337 // pinned. That is the harmless direction: no lead section, rather than two.
338 if updated.pinned {
339 for other in repos.list_by_org(&existing.org_id).await? {
340 if other.pinned && other.id != updated.id {
341 repos
342 .save(&Repository {
343 pinned: false,
344 ..other
345 })
346 .await?;
347 }
348 }
349 }
350
351 repos.save(&updated).await?;
352
353 Ok(updated)
354}
355
356/// Deletes a repository: its record, and the bare repo on disk.
357///
358/// Owner only, and irreversible — the git history goes with it.
359///
360/// **The row goes first, then the directory.** The two writes cannot share a
361/// transaction, so one of the two orphans is possible, and this picks the less harmful
362/// one deliberately. An orphaned *directory* only blocks reusing that name, and is
363/// already the documented failure mode of [`create_repo`]'s compensation path. An
364/// orphaned *row* is worse and visible: a repository that still lists on the profile and
365/// 404s the moment anyone clicks it.
366///
367/// If the directory cannot be removed this still reports success, because as far as
368/// Steid is concerned the repository genuinely is gone — there is nothing the caller
369/// could usefully do about it, and failing here would leave the visitor thinking the
370/// delete had not happened when the record is already destroyed. The failure is logged.
371pub async fn delete_repo(
372 actor: &Actor,
373 handle: &OrgName,
374 name: &RepoName,
375 orgs: &impl OrgRepository,
376 memberships: &impl MembershipRepository,
377 repos: &impl RepoRepository,
378 storage: &impl GitStorage,
379) -> Result<()> {
380 let repo = changeable_repo(actor, handle, name, orgs, memberships, repos).await?;
381
382 repos.delete(&repo.id).await?;
383
384 if let Err(error) = storage.remove(handle, &repo.name).await {
385 eprintln!(
386 "steid: repository {handle}/{} deleted, but its directory could not be removed: {error}",
387 repo.name
388 );
389 }
390
391 Ok(())
392}
393
394fn taken() -> Error {
395 DomainError::AlreadyExists {
396 entity: "repository",
397 }
398 .into()
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::{
405 domain::{
406 Membership, MembershipId, OrgId, Organization, RepoName, Role, UserId,
407 repository::{RepositoryError, RepositoryResult},
408 },
409 infrastructure::{
410 git::{DiskGitStorage, InMemoryGitStorage},
411 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
412 },
413 };
414
415 fn at(seconds: u64) -> SystemTime {
416 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds)
417 }
418
419 /// A `RepoRepository` whose `save` always fails, for exercising compensation.
420 ///
421 /// Test-local on purpose: fault injection does not belong in the shared fake, where
422 /// every other test would have to know about it.
423 #[derive(Debug, Default)]
424 struct FailingRepoRepo;
425
426 impl RepoRepository for FailingRepoRepo {
427 async fn find_by_id(&self, _id: &RepoId) -> RepositoryResult<Option<Repository>> {
428 Ok(None)
429 }
430
431 async fn find_by_org_and_name(
432 &self,
433 _org_id: &OrgId,
434 _name: &RepoName,
435 ) -> RepositoryResult<Option<Repository>> {
436 Ok(None)
437 }
438
439 async fn list_by_org(&self, _org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
440 Ok(Vec::new())
441 }
442
443 async fn save(&self, _repo: &Repository) -> RepositoryResult<()> {
444 Err(RepositoryError::backend("save failed on purpose"))
445 }
446
447 async fn touch(&self, _id: &RepoId, _now: SystemTime) -> RepositoryResult<()> {
448 Ok(())
449 }
450
451 async fn delete(&self, _id: &RepoId) -> RepositoryResult<()> {
452 Ok(())
453 }
454 }
455
456 struct Fixture {
457 orgs: InMemoryOrgRepo,
458 memberships: InMemoryMembershipRepo,
459 repos: InMemoryRepoRepo,
460 storage: InMemoryGitStorage,
461 owner: Actor,
462 member: Actor,
463 stranger: Actor,
464 handle: OrgName,
465 }
466
467 async fn fixture() -> Fixture {
468 let orgs = InMemoryOrgRepo::new();
469 let memberships = InMemoryMembershipRepo::new();
470
471 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
472 orgs.save(&org).await.expect("save org");
473
474 let owner = UserId::generate();
475 let member = UserId::generate();
476
477 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
478 memberships
479 .save(&Membership::new(
480 MembershipId::generate(),
481 org.id.clone(),
482 user.clone(),
483 role,
484 ))
485 .await
486 .expect("save membership");
487 }
488
489 Fixture {
490 orgs,
491 memberships,
492 repos: InMemoryRepoRepo::new(),
493 storage: InMemoryGitStorage::new(),
494 owner: Actor::User(owner),
495 member: Actor::User(member),
496 stranger: Actor::User(UserId::generate()),
497 handle: org.name,
498 }
499 }
500
501 fn spec(name: &str) -> NewRepo {
502 NewRepo {
503 name: name.to_owned(),
504 description: None,
505 visibility: Visibility::Public,
506 }
507 }
508
509 impl Fixture {
510 async fn create(&self, actor: &Actor, spec: &NewRepo) -> Result<Repository> {
511 create_repo(
512 actor,
513 &self.handle,
514 spec,
515 &self.orgs,
516 &self.memberships,
517 &self.repos,
518 &self.storage,
519 )
520 .await
521 }
522 }
523
524 #[tokio::test]
525 async fn the_owner_creates_a_record_and_a_bare_repo() {
526 let f = fixture().await;
527
528 let repo = f
529 .create(&f.owner, &spec("steid"))
530 .await
531 .expect("should create");
532
533 assert_eq!(repo.name.as_str(), "steid");
534 assert!(
535 f.repos
536 .find_by_org_and_name(&repo.org_id, &repo.name)
537 .await
538 .expect("lookup")
539 .is_some()
540 );
541 assert!(f.storage.contains(&f.handle, &repo.name));
542 }
543
544 #[tokio::test]
545 async fn an_anonymous_visitor_is_refused() {
546 let f = fixture().await;
547
548 let error = f
549 .create(&Actor::Anonymous, &spec("steid"))
550 .await
551 .expect_err("should refuse");
552
553 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
554 assert!(f.storage.is_empty());
555 }
556
557 #[tokio::test]
558 async fn a_signed_in_stranger_is_refused() {
559 // Signed in is not the same as allowed.
560 let f = fixture().await;
561
562 let error = f
563 .create(&f.stranger, &spec("steid"))
564 .await
565 .expect_err("should refuse");
566
567 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
568 assert!(f.storage.is_empty());
569 }
570
571 #[tokio::test]
572 async fn a_non_owner_member_is_refused() {
573 // Membership is read access, not permission to add to someone's portfolio.
574 let f = fixture().await;
575
576 let error = f
577 .create(&f.member, &spec("steid"))
578 .await
579 .expect_err("should refuse");
580
581 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
582 assert!(f.storage.is_empty());
583 }
584
585 #[tokio::test]
586 async fn an_unknown_handle_is_not_found() {
587 let f = fixture().await;
588 let missing = OrgName::new("nobody").expect("valid handle");
589
590 let error = create_repo(
591 &f.owner,
592 &missing,
593 &spec("steid"),
594 &f.orgs,
595 &f.memberships,
596 &f.repos,
597 &f.storage,
598 )
599 .await
600 .expect_err("should not find");
601
602 assert!(matches!(
603 error,
604 Error::Domain(DomainError::NotFound {
605 entity: "repository owner"
606 })
607 ));
608 }
609
610 #[tokio::test]
611 async fn an_invalid_name_writes_nothing() {
612 // Validation precedes both side effects, so a rejected name leaves no trace.
613 let f = fixture().await;
614
615 let error = f
616 .create(&f.owner, &spec("../escape"))
617 .await
618 .expect_err("should reject");
619
620 assert!(matches!(
621 error,
622 Error::Domain(DomainError::Validation { .. })
623 ));
624 assert!(f.storage.is_empty());
625 }
626
627 #[tokio::test]
628 async fn a_duplicate_name_is_refused_and_changes_nothing() {
629 let f = fixture().await;
630 let first = f
631 .create(&f.owner, &spec("steid"))
632 .await
633 .expect("should create");
634
635 let error = f
636 .create(&f.owner, &spec("steid"))
637 .await
638 .expect_err("should refuse");
639
640 assert!(matches!(
641 error,
642 Error::Domain(DomainError::AlreadyExists {
643 entity: "repository"
644 })
645 ));
646 assert_eq!(f.storage.len(), 1, "the existing repo should be untouched");
647 assert_eq!(
648 f.repos
649 .find_by_org_and_name(&first.org_id, &first.name)
650 .await
651 .expect("lookup")
652 .expect("still there")
653 .id,
654 first.id
655 );
656 }
657
658 #[tokio::test]
659 async fn a_duplicate_is_caught_case_insensitively() {
660 // The name normalises, so `Steid` and `steid` are the same repository.
661 let f = fixture().await;
662 f.create(&f.owner, &spec("steid"))
663 .await
664 .expect("should create");
665
666 let error = f
667 .create(&f.owner, &spec("Steid"))
668 .await
669 .expect_err("should refuse");
670
671 assert!(matches!(
672 error,
673 Error::Domain(DomainError::AlreadyExists {
674 entity: "repository"
675 })
676 ));
677 }
678
679 #[tokio::test]
680 async fn an_orphaned_directory_reads_as_a_taken_name() {
681 // No record, but the path is occupied — a create that died between the writes.
682 // The visitor is told the name is taken, because from outside it is.
683 let f = fixture().await;
684 let name = RepoName::new("steid").expect("valid");
685 f.storage
686 .init_bare(&f.handle, &name)
687 .await
688 .expect("orphan the directory");
689
690 let error = f
691 .create(&f.owner, &spec("steid"))
692 .await
693 .expect_err("should refuse");
694
695 assert!(matches!(
696 error,
697 Error::Domain(DomainError::AlreadyExists {
698 entity: "repository"
699 })
700 ));
701 }
702
703 #[tokio::test]
704 async fn the_name_is_normalised_in_what_comes_back() {
705 // The caller redirects using this, so it has to be the stored form.
706 let f = fixture().await;
707
708 let repo = f
709 .create(&f.owner, &spec(" MyRepo "))
710 .await
711 .expect("should create");
712
713 assert_eq!(repo.name.as_str(), "myrepo");
714 assert!(f.storage.contains(&f.handle, &repo.name));
715 }
716
717 #[tokio::test]
718 async fn visibility_and_description_are_carried_through() {
719 let f = fixture().await;
720
721 let repo = f
722 .create(
723 &f.owner,
724 &NewRepo {
725 name: "steid".to_owned(),
726 description: Some(" A gitforge. ".to_owned()),
727 visibility: Visibility::Private,
728 },
729 )
730 .await
731 .expect("should create");
732
733 assert_eq!(repo.visibility, Visibility::Private);
734 assert_eq!(repo.description.as_deref(), Some("A gitforge."));
735 }
736
737 #[tokio::test]
738 async fn a_blank_description_is_stored_as_unset() {
739 let f = fixture().await;
740
741 let repo = f
742 .create(
743 &f.owner,
744 &NewRepo {
745 name: "steid".to_owned(),
746 description: Some(" ".to_owned()),
747 visibility: Visibility::Public,
748 },
749 )
750 .await
751 .expect("should create");
752
753 assert_eq!(repo.description, None);
754 }
755
756 #[tokio::test]
757 async fn a_failed_save_removes_the_bare_repo() {
758 // The compensating transaction. Without it every failed insert leaks a
759 // directory that then blocks the name forever.
760 let f = fixture().await;
761
762 let error = create_repo(
763 &f.owner,
764 &f.handle,
765 &spec("steid"),
766 &f.orgs,
767 &f.memberships,
768 &FailingRepoRepo,
769 &f.storage,
770 )
771 .await
772 .expect_err("should fail");
773
774 assert!(matches!(error, Error::Repository(_)));
775 assert!(
776 f.storage.is_empty(),
777 "the bare repo should have been compensated away"
778 );
779 }
780
781 #[tokio::test]
782 async fn a_successful_save_keeps_the_bare_repo() {
783 // The other half of the above: compensation must not fire on the happy path.
784 let f = fixture().await;
785
786 let repo = f
787 .create(&f.owner, &spec("steid"))
788 .await
789 .expect("should create");
790
791 assert!(f.storage.contains(&f.handle, &repo.name));
792 }
793
794 #[tokio::test]
795 async fn a_compensated_name_can_be_created_again() {
796 let f = fixture().await;
797 let _ = create_repo(
798 &f.owner,
799 &f.handle,
800 &spec("steid"),
801 &f.orgs,
802 &f.memberships,
803 &FailingRepoRepo,
804 &f.storage,
805 )
806 .await;
807
808 f.create(&f.owner, &spec("steid"))
809 .await
810 .expect("the name should be free again");
811 }
812
813 /// The one test that runs the real adapter. Everything above proves the use case's
814 /// logic against a fake; this proves the record and the directory actually both
815 /// appear when it is wired to disk.
816 #[tokio::test]
817 async fn against_real_disk_storage_both_the_row_and_the_repo_appear() {
818 let f = fixture().await;
819 let dir = tempfile::TempDir::new().expect("temp dir");
820 let storage = DiskGitStorage::new(dir.path());
821
822 let repo = create_repo(
823 &f.owner,
824 &f.handle,
825 &spec("steid"),
826 &f.orgs,
827 &f.memberships,
828 &f.repos,
829 &storage,
830 )
831 .await
832 .expect("should create");
833
834 assert!(
835 f.repos
836 .find_by_id(&repo.id)
837 .await
838 .expect("lookup")
839 .is_some()
840 );
841 assert!(dir.path().join("acme").join("steid.git").is_dir());
842 }
843
844 // --- view_repo -------------------------------------------------------------
845
846 impl Fixture {
847 async fn view(&self, actor: &Actor, name: &str) -> Option<RepoView> {
848 view_repo(
849 &self.handle,
850 &RepoName::new(name).expect("valid name"),
851 actor,
852 &self.orgs,
853 &self.memberships,
854 &self.repos,
855 )
856 .await
857 .expect("lookup should not error")
858 }
859
860 /// Creates a repository and dates it, so an ordering test states its own times
861 /// rather than depending on how fast it runs.
862 async fn create_dated(&self, name: &str, seconds: u64) -> Repository {
863 let repo = self.create_with(Visibility::Public, name).await;
864 self.repos
865 .touch(&repo.id, at(seconds))
866 .await
867 .expect("touch");
868 Repository {
869 updated_at: at(seconds),
870 ..repo
871 }
872 }
873
874 async fn create_with(&self, visibility: Visibility, name: &str) -> Repository {
875 self.create(
876 &self.owner,
877 &NewRepo {
878 name: name.to_owned(),
879 description: None,
880 visibility,
881 },
882 )
883 .await
884 .expect("should create")
885 }
886 }
887
888 #[tokio::test]
889 async fn a_public_repository_is_visible_to_anyone() {
890 let f = fixture().await;
891 f.create_with(Visibility::Public, "steid").await;
892
893 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
894 assert!(
895 f.view(actor, "steid").await.is_some(),
896 "public repo should be visible to {actor:?}"
897 );
898 }
899 }
900
901 #[tokio::test]
902 async fn a_private_repository_is_absent_for_outsiders() {
903 // `None`, not an error: distinguishing "forbidden" from "missing" would confirm
904 // the repository exists and reveal its name.
905 let f = fixture().await;
906 f.create_with(Visibility::Private, "secret").await;
907
908 assert!(f.view(&Actor::Anonymous, "secret").await.is_none());
909 assert!(f.view(&f.stranger, "secret").await.is_none());
910 }
911
912 #[tokio::test]
913 async fn a_private_repository_is_visible_to_any_member() {
914 // Seeing is weaker than changing: a member who may not create repositories may
915 // still read the private ones.
916 let f = fixture().await;
917 f.create_with(Visibility::Private, "secret").await;
918
919 assert!(f.view(&f.member, "secret").await.is_some());
920 assert!(f.view(&f.owner, "secret").await.is_some());
921 }
922
923 #[tokio::test]
924 async fn viewer_is_owner_tracks_the_actor() {
925 let f = fixture().await;
926 f.create_with(Visibility::Public, "steid").await;
927
928 assert!(
929 f.view(&f.owner, "steid")
930 .await
931 .expect("visible")
932 .viewer_is_owner
933 );
934 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
935 assert!(
936 !f.view(actor, "steid")
937 .await
938 .expect("visible")
939 .viewer_is_owner,
940 "{actor:?} should not be treated as owner"
941 );
942 }
943 }
944
945 #[tokio::test]
946 async fn an_unknown_repository_is_absent() {
947 let f = fixture().await;
948 f.create_with(Visibility::Public, "steid").await;
949
950 assert!(f.view(&f.owner, "nothing-here").await.is_none());
951 }
952
953 #[tokio::test]
954 async fn an_unknown_handle_is_absent() {
955 let f = fixture().await;
956 let missing = OrgName::new("nobody").expect("valid handle");
957
958 let found = view_repo(
959 &missing,
960 &RepoName::new("steid").expect("valid"),
961 &f.owner,
962 &f.orgs,
963 &f.memberships,
964 &f.repos,
965 )
966 .await
967 .expect("lookup should not error");
968
969 assert!(found.is_none());
970 }
971
972 #[tokio::test]
973 async fn the_view_carries_what_a_page_needs() {
974 let f = fixture().await;
975 f.create(
976 &f.owner,
977 &NewRepo {
978 name: "steid".to_owned(),
979 description: Some("A gitforge.".to_owned()),
980 visibility: Visibility::Private,
981 },
982 )
983 .await
984 .expect("should create");
985
986 let view = f.view(&f.owner, "steid").await.expect("visible");
987
988 assert_eq!(view.handle.as_str(), "acme");
989 assert_eq!(view.name.as_str(), "steid");
990 assert_eq!(view.description.as_deref(), Some("A gitforge."));
991 assert_eq!(view.visibility, Visibility::Private);
992 }
993
994 #[tokio::test]
995 async fn lookup_is_case_insensitive_through_the_name_type() {
996 let f = fixture().await;
997 f.create_with(Visibility::Public, "MyRepo").await;
998
999 assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some());
1000 }
1001
1002 // --- list_repos ------------------------------------------------------------
1003
1004 impl Fixture {
1005 async fn list(&self, actor: &Actor) -> Vec<RepoSummary> {
1006 list_repos(
1007 &self.handle,
1008 actor,
1009 &self.orgs,
1010 &self.memberships,
1011 &self.repos,
1012 )
1013 .await
1014 .expect("listing should not error")
1015 .expect("the handle exists")
1016 }
1017
1018 fn names(summaries: &[RepoSummary]) -> Vec<&str> {
1019 summaries.iter().map(|repo| repo.name.as_str()).collect()
1020 }
1021 }
1022
1023 /// Two public and one private, dated so that recency and the alphabet disagree.
1024 ///
1025 /// `zebra` is the most recent and `alpha` the oldest, so a listing that came back
1026 /// alphabetical would be visibly wrong rather than accidentally right.
1027 async fn mixed() -> Fixture {
1028 let f = fixture().await;
1029 f.create_dated("zebra", 3_000).await;
1030 f.repos
1031 .save(&Repository {
1032 updated_at: at(2_000),
1033 ..f.create_with(Visibility::Private, "secret").await
1034 })
1035 .await
1036 .expect("date the private repo");
1037 f.create_dated("alpha", 1_000).await;
1038 f
1039 }
1040
1041 #[tokio::test]
1042 async fn outsiders_see_only_public_repositories() {
1043 let f = mixed().await;
1044
1045 for actor in [&Actor::Anonymous, &f.stranger] {
1046 let listed = f.list(actor).await;
1047 assert_eq!(
1048 Fixture::names(&listed),
1049 vec!["zebra", "alpha"],
1050 "{actor:?} should see only the public repositories"
1051 );
1052 }
1053 }
1054
1055 #[tokio::test]
1056 async fn members_and_owners_see_private_repositories_too() {
1057 let f = mixed().await;
1058
1059 for actor in [&f.member, &f.owner] {
1060 let listed = f.list(actor).await;
1061 assert_eq!(
1062 Fixture::names(&listed),
1063 vec!["zebra", "secret", "alpha"],
1064 "{actor:?} should see everything"
1065 );
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn listings_are_ordered_by_recency_not_by_name() {
1071 // Changed deliberately from alphabetical: the profile is a portfolio, and
1072 // alphabetical is a filing rule that puts `dotfiles` above the thing being built.
1073 let f = mixed().await;
1074
1075 assert_eq!(
1076 Fixture::names(&f.list(&f.owner).await),
1077 vec!["zebra", "secret", "alpha"]
1078 );
1079 }
1080
1081 #[tokio::test]
1082 async fn repositories_updated_in_the_same_second_are_ordered_by_name() {
1083 // Every repository that predates `updated_at` shares one timestamp, so without a
1084 // tiebreak a profile would reshuffle itself between page loads.
1085 let f = fixture().await;
1086 for name in ["zebra", "alpha", "middle"] {
1087 f.create_dated(name, 1_000).await;
1088 }
1089
1090 assert_eq!(
1091 Fixture::names(&f.list(&f.owner).await),
1092 vec!["alpha", "middle", "zebra"]
1093 );
1094 }
1095
1096 #[tokio::test]
1097 async fn a_new_repository_is_dated_when_it_was_created() {
1098 let f = fixture().await;
1099 let before = SystemTime::now();
1100
1101 let repo = f.create_with(Visibility::Public, "steid").await;
1102
1103 assert!(repo.updated_at >= before);
1104 assert!(repo.updated_at <= SystemTime::now());
1105 assert!(!repo.pinned, "nothing leads a profile by being created");
1106 }
1107
1108 #[tokio::test]
1109 async fn a_viewer_who_may_see_nothing_gets_an_empty_list() {
1110 // Not a count, not a hint. Either would leak that private repositories exist
1111 // and how many.
1112 let f = fixture().await;
1113 f.create_with(Visibility::Private, "secret").await;
1114 f.create_with(Visibility::Private, "other").await;
1115
1116 assert!(f.list(&Actor::Anonymous).await.is_empty());
1117 }
1118
1119 #[tokio::test]
1120 async fn a_handle_with_no_repositories_lists_nothing() {
1121 let f = fixture().await;
1122
1123 assert!(f.list(&f.owner).await.is_empty());
1124 }
1125
1126 #[tokio::test]
1127 async fn an_unknown_handle_is_none_not_an_empty_list() {
1128 // `/api` has to answer 404 for a handle that does not exist rather than `[]`.
1129 let f = fixture().await;
1130 let missing = OrgName::new("nobody").expect("valid handle");
1131
1132 let listed = list_repos(&missing, &f.owner, &f.orgs, &f.memberships, &f.repos)
1133 .await
1134 .expect("listing should not error");
1135
1136 assert!(listed.is_none());
1137 }
1138
1139 #[tokio::test]
1140 async fn a_summary_carries_what_a_listing_renders() {
1141 let f = fixture().await;
1142 f.create(
1143 &f.owner,
1144 &NewRepo {
1145 name: "steid".to_owned(),
1146 description: Some("A gitforge.".to_owned()),
1147 visibility: Visibility::Private,
1148 },
1149 )
1150 .await
1151 .expect("should create");
1152
1153 let listed = f.list(&f.owner).await;
1154 let summary = listed.first().expect("one repository");
1155
1156 assert_eq!(summary.name.as_str(), "steid");
1157 assert_eq!(summary.description.as_deref(), Some("A gitforge."));
1158 assert_eq!(summary.visibility, Visibility::Private);
1159 // The profile dates each row and picks its lead out of the listing, so both
1160 // travel with the summary rather than costing a second lookup.
1161 assert!(!summary.pinned);
1162 assert!(summary.updated_at <= SystemTime::now());
1163 }
1164
1165 #[tokio::test]
1166 async fn listing_only_covers_the_handle_asked_for() {
1167 let f = fixture().await;
1168 f.create_with(Visibility::Public, "mine").await;
1169
1170 let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
1171 f.orgs.save(&other).await.expect("save org");
1172 f.repos
1173 .save(
1174 &Repository::new(
1175 RepoId::generate(),
1176 other.id.clone(),
1177 "theirs",
1178 None,
1179 Visibility::Public,
1180 SystemTime::now(),
1181 )
1182 .expect("valid repo"),
1183 )
1184 .await
1185 .expect("save repo");
1186
1187 assert_eq!(
1188 Fixture::names(&f.list(&Actor::Anonymous).await),
1189 vec!["mine"]
1190 );
1191 }
1192 // --- update_repo -----------------------------------------------------------
1193
1194 impl Fixture {
1195 async fn update(
1196 &self,
1197 actor: &Actor,
1198 name: &str,
1199 description: Option<&str>,
1200 visibility: Visibility,
1201 ) -> Result<Repository> {
1202 update_repo(
1203 actor,
1204 &self.handle,
1205 &RepoName::new(name).expect("valid name"),
1206 &RepoEdit {
1207 description: description.map(str::to_owned),
1208 visibility,
1209 pinned: false,
1210 },
1211 &self.orgs,
1212 &self.memberships,
1213 &self.repos,
1214 )
1215 .await
1216 }
1217
1218 async fn stored(&self, name: &str) -> Option<Repository> {
1219 let org = self
1220 .orgs
1221 .find_by_name(&self.handle)
1222 .await
1223 .expect("lookup")
1224 .expect("the handle exists");
1225
1226 self.repos
1227 .find_by_org_and_name(&org.id, &RepoName::new(name).expect("valid name"))
1228 .await
1229 .expect("lookup")
1230 }
1231 }
1232
1233 #[tokio::test]
1234 async fn the_owner_changes_the_description_and_the_visibility() {
1235 let f = fixture().await;
1236 f.create_with(Visibility::Public, "steid").await;
1237
1238 let updated = f
1239 .update(
1240 &f.owner,
1241 "steid",
1242 Some(" A gitforge. "),
1243 Visibility::Private,
1244 )
1245 .await
1246 .expect("should update");
1247
1248 assert_eq!(updated.description.as_deref(), Some("A gitforge."));
1249 assert_eq!(updated.visibility, Visibility::Private);
1250
1251 let stored = f.stored("steid").await.expect("still there");
1252 assert_eq!(stored.description.as_deref(), Some("A gitforge."));
1253 assert_eq!(stored.visibility, Visibility::Private);
1254 }
1255
1256 #[tokio::test]
1257 async fn a_description_can_be_cleared() {
1258 let f = fixture().await;
1259 f.create(
1260 &f.owner,
1261 &NewRepo {
1262 name: "steid".to_owned(),
1263 description: Some("A gitforge.".to_owned()),
1264 visibility: Visibility::Public,
1265 },
1266 )
1267 .await
1268 .expect("should create");
1269
1270 f.update(&f.owner, "steid", None, Visibility::Public)
1271 .await
1272 .expect("should update");
1273
1274 assert_eq!(
1275 f.stored("steid").await.expect("still there").description,
1276 None
1277 );
1278 }
1279
1280 #[tokio::test]
1281 async fn an_update_never_touches_the_name_or_the_directory() {
1282 // Renaming is a directory move, not a column update, so it is deliberately not
1283 // offered here — and an update must not disturb what is on disk.
1284 let f = fixture().await;
1285 let created = f.create_with(Visibility::Public, "steid").await;
1286
1287 let updated = f
1288 .update(&f.owner, "steid", Some("changed"), Visibility::Private)
1289 .await
1290 .expect("should update");
1291
1292 assert_eq!(updated.name, created.name);
1293 assert_eq!(updated.id, created.id);
1294 assert!(f.storage.contains(&f.handle, &created.name));
1295 assert_eq!(f.storage.len(), 1);
1296 }
1297
1298 #[tokio::test]
1299 async fn a_member_who_is_not_the_owner_cannot_change_a_repository() {
1300 // The repository is public, so the member can see it; seeing is not changing.
1301 let f = fixture().await;
1302 f.create_with(Visibility::Public, "steid").await;
1303
1304 let error = f
1305 .update(&f.member, "steid", Some("mine now"), Visibility::Private)
1306 .await
1307 .expect_err("should refuse");
1308
1309 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
1310 assert_eq!(
1311 f.stored("steid").await.expect("untouched").visibility,
1312 Visibility::Public
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn a_stranger_and_an_anonymous_visitor_cannot_change_a_repository() {
1318 let f = fixture().await;
1319 f.create_with(Visibility::Public, "steid").await;
1320
1321 for actor in [&Actor::Anonymous, &f.stranger] {
1322 let error = f
1323 .update(actor, "steid", Some("mine now"), Visibility::Private)
1324 .await
1325 .expect_err("should refuse");
1326
1327 assert!(
1328 matches!(error, Error::Domain(DomainError::Forbidden)),
1329 "{actor:?} should be refused"
1330 );
1331 }
1332
1333 assert_eq!(
1334 f.stored("steid").await.expect("untouched").description,
1335 None
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn a_private_repository_is_not_found_rather_than_forbidden_for_an_outsider() {
1341 // The established rule: "forbidden" would confirm that a private repository by
1342 // that name exists, which is exactly what private is protecting.
1343 let f = fixture().await;
1344 f.create_with(Visibility::Private, "secret").await;
1345
1346 for actor in [&Actor::Anonymous, &f.stranger] {
1347 let error = f
1348 .update(actor, "secret", None, Visibility::Public)
1349 .await
1350 .expect_err("should refuse");
1351
1352 assert!(
1353 matches!(
1354 error,
1355 Error::Domain(DomainError::NotFound {
1356 entity: "repository"
1357 })
1358 ),
1359 "{actor:?} should be told it does not exist"
1360 );
1361 }
1362
1363 assert_eq!(
1364 f.stored("secret").await.expect("untouched").visibility,
1365 Visibility::Private
1366 );
1367 }
1368
1369 #[tokio::test]
1370 async fn an_unknown_repository_or_handle_is_not_found() {
1371 let f = fixture().await;
1372
1373 let error = f
1374 .update(&f.owner, "nothing-here", None, Visibility::Public)
1375 .await
1376 .expect_err("should refuse");
1377
1378 assert!(matches!(
1379 error,
1380 Error::Domain(DomainError::NotFound {
1381 entity: "repository"
1382 })
1383 ));
1384
1385 let missing = OrgName::new("nobody").expect("valid handle");
1386 let error = update_repo(
1387 &f.owner,
1388 &missing,
1389 &RepoName::new("steid").expect("valid"),
1390 &RepoEdit {
1391 description: None,
1392 visibility: Visibility::Public,
1393 pinned: false,
1394 },
1395 &f.orgs,
1396 &f.memberships,
1397 &f.repos,
1398 )
1399 .await
1400 .expect_err("should refuse");
1401
1402 assert!(matches!(
1403 error,
1404 Error::Domain(DomainError::NotFound {
1405 entity: "repository"
1406 })
1407 ));
1408 }
1409
1410 #[tokio::test]
1411 async fn an_over_long_description_is_rejected_and_changes_nothing() {
1412 let f = fixture().await;
1413 f.create_with(Visibility::Public, "steid").await;
1414 let long = "a".repeat(Repository::MAX_DESCRIPTION_LEN + 1);
1415
1416 let error = f
1417 .update(&f.owner, "steid", Some(&long), Visibility::Private)
1418 .await
1419 .expect_err("should reject");
1420
1421 assert!(matches!(
1422 error,
1423 Error::Domain(DomainError::Validation { .. })
1424 ));
1425 let stored = f.stored("steid").await.expect("untouched");
1426 assert_eq!(stored.description, None);
1427 assert_eq!(stored.visibility, Visibility::Public);
1428 }
1429
1430 #[tokio::test]
1431 async fn making_a_public_repository_private_hides_it_from_outsiders() {
1432 // The hole this closes: someone who published by accident can un-publish.
1433 let f = fixture().await;
1434 f.create_with(Visibility::Public, "oops").await;
1435 assert!(f.view(&Actor::Anonymous, "oops").await.is_some());
1436
1437 f.update(&f.owner, "oops", None, Visibility::Private)
1438 .await
1439 .expect("should update");
1440
1441 assert!(
1442 f.view(&Actor::Anonymous, "oops").await.is_none(),
1443 "it should be absent, not merely unlinked"
1444 );
1445 assert!(f.list(&Actor::Anonymous).await.is_empty());
1446 assert!(f.view(&f.owner, "oops").await.is_some());
1447 }
1448
1449 #[tokio::test]
1450 async fn making_a_private_repository_public_reveals_it() {
1451 let f = fixture().await;
1452 f.create_with(Visibility::Private, "secret").await;
1453
1454 f.update(&f.owner, "secret", None, Visibility::Public)
1455 .await
1456 .expect("should update");
1457
1458 assert!(f.view(&Actor::Anonymous, "secret").await.is_some());
1459 assert_eq!(
1460 Fixture::names(&f.list(&Actor::Anonymous).await),
1461 vec!["secret"]
1462 );
1463 }
1464
1465 // --- pinning ---------------------------------------------------------------
1466
1467 impl Fixture {
1468 /// Sets the pin, leaving everything else as stored.
1469 async fn set_pin(&self, actor: &Actor, name: &str, pinned: bool) -> Result<Repository> {
1470 let existing = self.stored(name).await.expect("the repository exists");
1471
1472 update_repo(
1473 actor,
1474 &self.handle,
1475 &existing.name,
1476 &RepoEdit {
1477 description: existing.description.clone(),
1478 visibility: existing.visibility,
1479 pinned,
1480 },
1481 &self.orgs,
1482 &self.memberships,
1483 &self.repos,
1484 )
1485 .await
1486 }
1487
1488 async fn pinned_names(&self) -> Vec<String> {
1489 self.list(&self.owner)
1490 .await
1491 .into_iter()
1492 .filter(|summary| summary.pinned)
1493 .map(|summary| summary.name.to_string())
1494 .collect()
1495 }
1496 }
1497
1498 #[tokio::test]
1499 async fn the_owner_pins_a_repository() {
1500 let f = fixture().await;
1501 f.create_with(Visibility::Public, "steid").await;
1502
1503 let pinned = f
1504 .set_pin(&f.owner, "steid", true)
1505 .await
1506 .expect("should pin");
1507
1508 assert!(pinned.pinned);
1509 assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1510 }
1511
1512 #[tokio::test]
1513 async fn pinning_a_second_repository_unpins_the_first() {
1514 // At most one lead per owner. Two would leave the profile with no rule for
1515 // choosing between them.
1516 let f = fixture().await;
1517 f.create_with(Visibility::Public, "steid").await;
1518 f.create_with(Visibility::Public, "dotfiles").await;
1519 f.set_pin(&f.owner, "steid", true)
1520 .await
1521 .expect("should pin");
1522
1523 f.set_pin(&f.owner, "dotfiles", true)
1524 .await
1525 .expect("should pin");
1526
1527 assert_eq!(f.pinned_names().await, vec!["dotfiles".to_owned()]);
1528 }
1529
1530 #[tokio::test]
1531 async fn re_pinning_the_same_repository_leaves_it_pinned() {
1532 // The unpin sweep skips the repository being saved; getting that wrong would
1533 // make a second save of an unchanged form silently clear the pin.
1534 let f = fixture().await;
1535 f.create_with(Visibility::Public, "steid").await;
1536 f.set_pin(&f.owner, "steid", true)
1537 .await
1538 .expect("should pin");
1539
1540 f.set_pin(&f.owner, "steid", true)
1541 .await
1542 .expect("should pin");
1543
1544 assert_eq!(f.pinned_names().await, vec!["steid".to_owned()]);
1545 }
1546
1547 #[tokio::test]
1548 async fn unpinning_leaves_nothing_pinned() {
1549 let f = fixture().await;
1550 f.create_with(Visibility::Public, "steid").await;
1551 f.set_pin(&f.owner, "steid", true)
1552 .await
1553 .expect("should pin");
1554
1555 f.set_pin(&f.owner, "steid", false)
1556 .await
1557 .expect("should unpin");
1558
1559 assert!(f.pinned_names().await.is_empty());
1560 }
1561
1562 #[tokio::test]
1563 async fn pinning_only_reaches_the_owners_own_repositories() {
1564 // The sweep is scoped to the org. Another owner's lead is not this owner's to
1565 // clear.
1566 let f = fixture().await;
1567 f.create_with(Visibility::Public, "steid").await;
1568
1569 let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
1570 f.orgs.save(&other).await.expect("save org");
1571 let mut theirs = Repository::new(
1572 RepoId::generate(),
1573 other.id.clone(),
1574 "theirs",
1575 None,
1576 Visibility::Public,
1577 SystemTime::now(),
1578 )
1579 .expect("valid repo");
1580 theirs.pinned = true;
1581 f.repos.save(&theirs).await.expect("save repo");
1582
1583 f.set_pin(&f.owner, "steid", true)
1584 .await
1585 .expect("should pin");
1586
1587 assert!(
1588 f.repos
1589 .find_by_id(&theirs.id)
1590 .await
1591 .expect("lookup")
1592 .expect("still there")
1593 .pinned,
1594 "another owner's lead should be untouched"
1595 );
1596 }
1597
1598 #[tokio::test]
1599 async fn a_member_who_is_not_the_owner_cannot_pin() {
1600 // Pinning is a mutation, and every repository mutation is owner-only.
1601 let f = fixture().await;
1602 f.create_with(Visibility::Public, "steid").await;
1603
1604 for actor in [&f.member, &f.stranger, &Actor::Anonymous] {
1605 let error = f
1606 .set_pin(actor, "steid", true)
1607 .await
1608 .expect_err("should refuse");
1609
1610 assert!(
1611 matches!(error, Error::Domain(DomainError::Forbidden)),
1612 "{actor:?} should be forbidden, got {error:?}"
1613 );
1614 }
1615
1616 assert!(f.pinned_names().await.is_empty());
1617 }
1618
1619 #[tokio::test]
1620 async fn editing_a_repository_does_not_move_its_place_in_the_listing() {
1621 // `updated_at` means "code last landed here". Rewording a description must not
1622 // jump a dormant repository to the top of a portfolio.
1623 let f = fixture().await;
1624 let repo = f.create_dated("steid", 1_000).await;
1625
1626 let updated = f
1627 .update(&f.owner, "steid", Some("A gitforge."), Visibility::Public)
1628 .await
1629 .expect("should update");
1630
1631 assert_eq!(updated.updated_at, repo.updated_at);
1632 }
1633
1634 // --- delete_repo -----------------------------------------------------------
1635
1636 /// Git storage whose `remove` always fails, for the best-effort path.
1637 #[derive(Debug, Default)]
1638 struct FailingRemoveStorage;
1639
1640 impl GitStorage for FailingRemoveStorage {
1641 async fn init_bare(
1642 &self,
1643 _handle: &OrgName,
1644 _name: &RepoName,
1645 ) -> std::result::Result<(), GitStorageError> {
1646 Ok(())
1647 }
1648
1649 async fn remove(
1650 &self,
1651 _handle: &OrgName,
1652 _name: &RepoName,
1653 ) -> std::result::Result<(), GitStorageError> {
1654 Err(GitStorageError::backend("remove failed on purpose"))
1655 }
1656
1657 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> std::path::PathBuf {
1658 std::path::PathBuf::from(format!("{handle}/{name}.git"))
1659 }
1660 }
1661
1662 impl Fixture {
1663 async fn delete(&self, actor: &Actor, name: &str) -> Result<()> {
1664 delete_repo(
1665 actor,
1666 &self.handle,
1667 &RepoName::new(name).expect("valid name"),
1668 &self.orgs,
1669 &self.memberships,
1670 &self.repos,
1671 &self.storage,
1672 )
1673 .await
1674 }
1675 }
1676
1677 #[tokio::test]
1678 async fn the_owner_deletes_the_record_and_the_bare_repo() {
1679 let f = fixture().await;
1680 f.create_with(Visibility::Public, "steid").await;
1681
1682 f.delete(&f.owner, "steid").await.expect("should delete");
1683
1684 assert!(f.stored("steid").await.is_none());
1685 assert!(f.storage.is_empty(), "the directory should be gone too");
1686 assert!(f.view(&f.owner, "steid").await.is_none());
1687 assert!(f.list(&f.owner).await.is_empty());
1688 }
1689
1690 #[tokio::test]
1691 async fn a_non_owner_deletes_neither_the_record_nor_the_directory() {
1692 let f = fixture().await;
1693 let repo = f.create_with(Visibility::Public, "steid").await;
1694
1695 for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
1696 let error = f.delete(actor, "steid").await.expect_err("should refuse");
1697
1698 assert!(
1699 matches!(error, Error::Domain(DomainError::Forbidden)),
1700 "{actor:?} should be refused"
1701 );
1702 }
1703
1704 assert!(f.stored("steid").await.is_some());
1705 assert!(f.storage.contains(&f.handle, &repo.name));
1706 }
1707
1708 #[tokio::test]
1709 async fn a_private_repository_is_not_found_for_an_outsider_asking_to_delete_it() {
1710 let f = fixture().await;
1711 let repo = f.create_with(Visibility::Private, "secret").await;
1712
1713 let error = f
1714 .delete(&f.stranger, "secret")
1715 .await
1716 .expect_err("should refuse");
1717
1718 assert!(matches!(
1719 error,
1720 Error::Domain(DomainError::NotFound {
1721 entity: "repository"
1722 })
1723 ));
1724 assert!(f.stored("secret").await.is_some());
1725 assert!(f.storage.contains(&f.handle, &repo.name));
1726 }
1727
1728 #[tokio::test]
1729 async fn deleting_a_repository_that_does_not_exist_is_not_found() {
1730 let f = fixture().await;
1731
1732 let error = f
1733 .delete(&f.owner, "nothing-here")
1734 .await
1735 .expect_err("should refuse");
1736
1737 assert!(matches!(
1738 error,
1739 Error::Domain(DomainError::NotFound {
1740 entity: "repository"
1741 })
1742 ));
1743 }
1744
1745 #[tokio::test]
1746 async fn deleting_one_repository_leaves_the_others_alone() {
1747 let f = fixture().await;
1748 f.create_with(Visibility::Public, "keep").await;
1749 f.create_with(Visibility::Public, "drop").await;
1750
1751 f.delete(&f.owner, "drop").await.expect("should delete");
1752
1753 assert_eq!(Fixture::names(&f.list(&f.owner).await), vec!["keep"]);
1754 assert_eq!(f.storage.len(), 1);
1755 }
1756
1757 #[tokio::test]
1758 async fn a_name_freed_by_deletion_can_be_created_again() {
1759 let f = fixture().await;
1760 f.create_with(Visibility::Public, "steid").await;
1761 f.delete(&f.owner, "steid").await.expect("should delete");
1762
1763 let recreated = f
1764 .create(&f.owner, &spec("steid"))
1765 .await
1766 .expect("the name should be free again");
1767
1768 assert!(f.storage.contains(&f.handle, &recreated.name));
1769 }
1770
1771 #[tokio::test]
1772 async fn a_directory_that_will_not_delete_still_reports_success() {
1773 // The row goes first and is already gone; as far as Steid is concerned the
1774 // repository is deleted, and there is nothing the caller could do about the
1775 // leftover directory. The failure is logged, not returned.
1776 let f = fixture().await;
1777 f.create_with(Visibility::Public, "steid").await;
1778
1779 delete_repo(
1780 &f.owner,
1781 &f.handle,
1782 &RepoName::new("steid").expect("valid"),
1783 &f.orgs,
1784 &f.memberships,
1785 &f.repos,
1786 &FailingRemoveStorage,
1787 )
1788 .await
1789 .expect("should still report success");
1790
1791 assert!(f.stored("steid").await.is_none());
1792 }
1793
1794 /// The real adapter, so that "the directory is gone" is more than a fake's opinion.
1795 #[tokio::test]
1796 async fn against_real_disk_storage_delete_removes_the_directory() {
1797 let f = fixture().await;
1798 let dir = tempfile::TempDir::new().expect("temp dir");
1799 let storage = DiskGitStorage::new(dir.path());
1800
1801 create_repo(
1802 &f.owner,
1803 &f.handle,
1804 &spec("steid"),
1805 &f.orgs,
1806 &f.memberships,
1807 &f.repos,
1808 &storage,
1809 )
1810 .await
1811 .expect("should create");
1812 assert!(dir.path().join("acme").join("steid.git").is_dir());
1813
1814 delete_repo(
1815 &f.owner,
1816 &f.handle,
1817 &RepoName::new("steid").expect("valid"),
1818 &f.orgs,
1819 &f.memberships,
1820 &f.repos,
1821 &storage,
1822 )
1823 .await
1824 .expect("should delete");
1825
1826 assert!(!dir.path().join("acme").join("steid.git").exists());
1827 assert!(f.stored("steid").await.is_none());
1828 }
1829}