steid

@jamesgill /

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