steid

@jamesgill /

15.6 KBCode·Blame·Raw
1use crate::domain::{
2 Actor, DomainError, OrgName, RepoId, Repository, Visibility,
3 repository::{MembershipRepository, OrgRepository, RepoRepository},
4};
5
6use super::{
7 authz::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
102fn taken() -> Error {
103 DomainError::AlreadyExists {
104 entity: "repository",
105 }
106 .into()
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::{
113 domain::{
114 Membership, MembershipId, OrgId, Organization, RepoName, Role, UserId,
115 repository::{RepositoryError, RepositoryResult},
116 },
117 infrastructure::{
118 git::{DiskGitStorage, InMemoryGitStorage},
119 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
120 },
121 };
122
123 /// A `RepoRepository` whose `save` always fails, for exercising compensation.
124 ///
125 /// Test-local on purpose: fault injection does not belong in the shared fake, where
126 /// every other test would have to know about it.
127 #[derive(Debug, Default)]
128 struct FailingRepoRepo;
129
130 impl RepoRepository for FailingRepoRepo {
131 async fn find_by_id(&self, _id: &RepoId) -> RepositoryResult<Option<Repository>> {
132 Ok(None)
133 }
134
135 async fn find_by_org_and_name(
136 &self,
137 _org_id: &OrgId,
138 _name: &RepoName,
139 ) -> RepositoryResult<Option<Repository>> {
140 Ok(None)
141 }
142
143 async fn list_by_org(&self, _org_id: &OrgId) -> RepositoryResult<Vec<Repository>> {
144 Ok(Vec::new())
145 }
146
147 async fn save(&self, _repo: &Repository) -> RepositoryResult<()> {
148 Err(RepositoryError::backend("save failed on purpose"))
149 }
150 }
151
152 struct Fixture {
153 orgs: InMemoryOrgRepo,
154 memberships: InMemoryMembershipRepo,
155 repos: InMemoryRepoRepo,
156 storage: InMemoryGitStorage,
157 owner: Actor,
158 member: Actor,
159 stranger: Actor,
160 handle: OrgName,
161 }
162
163 async fn fixture() -> Fixture {
164 let orgs = InMemoryOrgRepo::new();
165 let memberships = InMemoryMembershipRepo::new();
166
167 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
168 orgs.save(&org).await.expect("save org");
169
170 let owner = UserId::generate();
171 let member = UserId::generate();
172
173 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
174 memberships
175 .save(&Membership::new(
176 MembershipId::generate(),
177 org.id.clone(),
178 user.clone(),
179 role,
180 ))
181 .await
182 .expect("save membership");
183 }
184
185 Fixture {
186 orgs,
187 memberships,
188 repos: InMemoryRepoRepo::new(),
189 storage: InMemoryGitStorage::new(),
190 owner: Actor::User(owner),
191 member: Actor::User(member),
192 stranger: Actor::User(UserId::generate()),
193 handle: org.name,
194 }
195 }
196
197 fn spec(name: &str) -> NewRepo {
198 NewRepo {
199 name: name.to_owned(),
200 description: None,
201 visibility: Visibility::Public,
202 }
203 }
204
205 impl Fixture {
206 async fn create(&self, actor: &Actor, spec: &NewRepo) -> Result<Repository> {
207 create_repo(
208 actor,
209 &self.handle,
210 spec,
211 &self.orgs,
212 &self.memberships,
213 &self.repos,
214 &self.storage,
215 )
216 .await
217 }
218 }
219
220 #[tokio::test]
221 async fn the_owner_creates_a_record_and_a_bare_repo() {
222 let f = fixture().await;
223
224 let repo = f
225 .create(&f.owner, &spec("steid"))
226 .await
227 .expect("should create");
228
229 assert_eq!(repo.name.as_str(), "steid");
230 assert!(
231 f.repos
232 .find_by_org_and_name(&repo.org_id, &repo.name)
233 .await
234 .expect("lookup")
235 .is_some()
236 );
237 assert!(f.storage.contains(&f.handle, &repo.name));
238 }
239
240 #[tokio::test]
241 async fn an_anonymous_visitor_is_refused() {
242 let f = fixture().await;
243
244 let error = f
245 .create(&Actor::Anonymous, &spec("steid"))
246 .await
247 .expect_err("should refuse");
248
249 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
250 assert!(f.storage.is_empty());
251 }
252
253 #[tokio::test]
254 async fn a_signed_in_stranger_is_refused() {
255 // Signed in is not the same as allowed.
256 let f = fixture().await;
257
258 let error = f
259 .create(&f.stranger, &spec("steid"))
260 .await
261 .expect_err("should refuse");
262
263 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
264 assert!(f.storage.is_empty());
265 }
266
267 #[tokio::test]
268 async fn a_non_owner_member_is_refused() {
269 // Membership is read access, not permission to add to someone's portfolio.
270 let f = fixture().await;
271
272 let error = f
273 .create(&f.member, &spec("steid"))
274 .await
275 .expect_err("should refuse");
276
277 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
278 assert!(f.storage.is_empty());
279 }
280
281 #[tokio::test]
282 async fn an_unknown_handle_is_not_found() {
283 let f = fixture().await;
284 let missing = OrgName::new("nobody").expect("valid handle");
285
286 let error = create_repo(
287 &f.owner,
288 &missing,
289 &spec("steid"),
290 &f.orgs,
291 &f.memberships,
292 &f.repos,
293 &f.storage,
294 )
295 .await
296 .expect_err("should not find");
297
298 assert!(matches!(
299 error,
300 Error::Domain(DomainError::NotFound {
301 entity: "repository owner"
302 })
303 ));
304 }
305
306 #[tokio::test]
307 async fn an_invalid_name_writes_nothing() {
308 // Validation precedes both side effects, so a rejected name leaves no trace.
309 let f = fixture().await;
310
311 let error = f
312 .create(&f.owner, &spec("../escape"))
313 .await
314 .expect_err("should reject");
315
316 assert!(matches!(
317 error,
318 Error::Domain(DomainError::Validation { .. })
319 ));
320 assert!(f.storage.is_empty());
321 }
322
323 #[tokio::test]
324 async fn a_duplicate_name_is_refused_and_changes_nothing() {
325 let f = fixture().await;
326 let first = f
327 .create(&f.owner, &spec("steid"))
328 .await
329 .expect("should create");
330
331 let error = f
332 .create(&f.owner, &spec("steid"))
333 .await
334 .expect_err("should refuse");
335
336 assert!(matches!(
337 error,
338 Error::Domain(DomainError::AlreadyExists {
339 entity: "repository"
340 })
341 ));
342 assert_eq!(f.storage.len(), 1, "the existing repo should be untouched");
343 assert_eq!(
344 f.repos
345 .find_by_org_and_name(&first.org_id, &first.name)
346 .await
347 .expect("lookup")
348 .expect("still there")
349 .id,
350 first.id
351 );
352 }
353
354 #[tokio::test]
355 async fn a_duplicate_is_caught_case_insensitively() {
356 // The name normalises, so `Steid` and `steid` are the same repository.
357 let f = fixture().await;
358 f.create(&f.owner, &spec("steid"))
359 .await
360 .expect("should create");
361
362 let error = f
363 .create(&f.owner, &spec("Steid"))
364 .await
365 .expect_err("should refuse");
366
367 assert!(matches!(
368 error,
369 Error::Domain(DomainError::AlreadyExists {
370 entity: "repository"
371 })
372 ));
373 }
374
375 #[tokio::test]
376 async fn an_orphaned_directory_reads_as_a_taken_name() {
377 // No record, but the path is occupied — a create that died between the writes.
378 // The visitor is told the name is taken, because from outside it is.
379 let f = fixture().await;
380 let name = RepoName::new("steid").expect("valid");
381 f.storage
382 .init_bare(&f.handle, &name)
383 .await
384 .expect("orphan the directory");
385
386 let error = f
387 .create(&f.owner, &spec("steid"))
388 .await
389 .expect_err("should refuse");
390
391 assert!(matches!(
392 error,
393 Error::Domain(DomainError::AlreadyExists {
394 entity: "repository"
395 })
396 ));
397 }
398
399 #[tokio::test]
400 async fn the_name_is_normalised_in_what_comes_back() {
401 // The caller redirects using this, so it has to be the stored form.
402 let f = fixture().await;
403
404 let repo = f
405 .create(&f.owner, &spec(" MyRepo "))
406 .await
407 .expect("should create");
408
409 assert_eq!(repo.name.as_str(), "myrepo");
410 assert!(f.storage.contains(&f.handle, &repo.name));
411 }
412
413 #[tokio::test]
414 async fn visibility_and_description_are_carried_through() {
415 let f = fixture().await;
416
417 let repo = f
418 .create(
419 &f.owner,
420 &NewRepo {
421 name: "steid".to_owned(),
422 description: Some(" A gitforge. ".to_owned()),
423 visibility: Visibility::Private,
424 },
425 )
426 .await
427 .expect("should create");
428
429 assert_eq!(repo.visibility, Visibility::Private);
430 assert_eq!(repo.description.as_deref(), Some("A gitforge."));
431 }
432
433 #[tokio::test]
434 async fn a_blank_description_is_stored_as_unset() {
435 let f = fixture().await;
436
437 let repo = f
438 .create(
439 &f.owner,
440 &NewRepo {
441 name: "steid".to_owned(),
442 description: Some(" ".to_owned()),
443 visibility: Visibility::Public,
444 },
445 )
446 .await
447 .expect("should create");
448
449 assert_eq!(repo.description, None);
450 }
451
452 #[tokio::test]
453 async fn a_failed_save_removes_the_bare_repo() {
454 // The compensating transaction. Without it every failed insert leaks a
455 // directory that then blocks the name forever.
456 let f = fixture().await;
457
458 let error = create_repo(
459 &f.owner,
460 &f.handle,
461 &spec("steid"),
462 &f.orgs,
463 &f.memberships,
464 &FailingRepoRepo,
465 &f.storage,
466 )
467 .await
468 .expect_err("should fail");
469
470 assert!(matches!(error, Error::Repository(_)));
471 assert!(
472 f.storage.is_empty(),
473 "the bare repo should have been compensated away"
474 );
475 }
476
477 #[tokio::test]
478 async fn a_successful_save_keeps_the_bare_repo() {
479 // The other half of the above: compensation must not fire on the happy path.
480 let f = fixture().await;
481
482 let repo = f
483 .create(&f.owner, &spec("steid"))
484 .await
485 .expect("should create");
486
487 assert!(f.storage.contains(&f.handle, &repo.name));
488 }
489
490 #[tokio::test]
491 async fn a_compensated_name_can_be_created_again() {
492 let f = fixture().await;
493 let _ = create_repo(
494 &f.owner,
495 &f.handle,
496 &spec("steid"),
497 &f.orgs,
498 &f.memberships,
499 &FailingRepoRepo,
500 &f.storage,
501 )
502 .await;
503
504 f.create(&f.owner, &spec("steid"))
505 .await
506 .expect("the name should be free again");
507 }
508
509 /// The one test that runs the real adapter. Everything above proves the use case's
510 /// logic against a fake; this proves the record and the directory actually both
511 /// appear when it is wired to disk.
512 #[tokio::test]
513 async fn against_real_disk_storage_both_the_row_and_the_repo_appear() {
514 let f = fixture().await;
515 let dir = tempfile::TempDir::new().expect("temp dir");
516 let storage = DiskGitStorage::new(dir.path());
517
518 let repo = create_repo(
519 &f.owner,
520 &f.handle,
521 &spec("steid"),
522 &f.orgs,
523 &f.memberships,
524 &f.repos,
525 &storage,
526 )
527 .await
528 .expect("should create");
529
530 assert!(
531 f.repos
532 .find_by_id(&repo.id)
533 .await
534 .expect("lookup")
535 .is_some()
536 );
537 assert!(dir.path().join("acme").join("steid.git").is_dir());
538 }
539}