steid

@jamesgill /

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