steid

@jamesgill /

Rust27.3 KBCode·Blame·History·Raw
1//! Importing a repository from a public URL.
2//!
3//! Two halves, on purpose. [`import_repo`] is the part a person waits for: it
4//! authorizes, validates the address, and writes a row that says a copy is coming.
5//! [`run_import`] is the part that takes minutes and runs with nobody watching. The
6//! seam between them is the `importing` row — which is also what makes the failure
7//! modes tractable, because a process that dies leaves a fact behind rather than
8//! nothing.
9
10use std::time::SystemTime;
11
12use crate::domain::{
13 Actor, DomainError, ImportSource, ImportState, OrgName, RepoId, RepoName, Repository,
14 Visibility,
15 repository::{MembershipRepository, OrgRepository, RepoRepository},
16};
17
18use super::{
19 access::{Stores, changeable_repo, is_org_owner},
20 error::{Error, Result},
21 port::{GitImport, GitStorage},
22};
23
24/// What is recorded against a repository whose import was cut short by the process
25/// stopping. See [`sweep_interrupted_imports`].
26pub const INTERRUPTED: &str =
27 "The import was interrupted by a restart. Delete this repository and import it again.";
28
29/// Longest failure message kept on a repository.
30///
31/// git's stderr on a failed clone is usually two lines and occasionally hundreds; the
32/// column is rendered on a page, so it is bounded here rather than trusted.
33const MAX_ERROR_LEN: usize = 500;
34
35/// The repository a visitor is asking to copy in.
36///
37/// Raw input, like [`NewRepo`](super::NewRepo): both fields are whatever was typed, and
38/// validating them is [`ImportSource`] and [`Repository::new`]'s job.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct NewImport {
41 pub name: String,
42 pub source: String,
43}
44
45/// Records that a repository is being imported, and returns it.
46///
47/// Owner only — the same rule as [`create_repo`](super::create_repo), and for the same
48/// reason: a member's read access is not permission to add to someone's portfolio.
49///
50/// **Nothing is written to disk here.** Unlike `create_repo`, which inits a bare
51/// repository and compensates if the row fails to save, an import's directory is
52/// created by `git clone` itself in the background. So the row goes first and is the
53/// only write this function makes, which removes the two-writes problem from the
54/// request path entirely — at the cost of moving it into [`run_import`], where a
55/// failure has somewhere to be recorded.
56///
57/// The caller is expected to spawn [`run_import`] afterwards. It is deliberately not
58/// spawned here: the application layer has no business owning a runtime, and a use case
59/// that starts a task cannot be tested without waiting for one.
60pub async fn import_repo(
61 actor: &Actor,
62 handle: &OrgName,
63 spec: &NewImport,
64 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
65) -> Result<Repository> {
66 let Some(org) = stores.orgs.find_by_name(handle).await? else {
67 return Err(DomainError::NotFound {
68 entity: "repository owner",
69 }
70 .into());
71 };
72
73 if !is_org_owner(&org, actor, stores.memberships).await? {
74 return Err(DomainError::Forbidden.into());
75 }
76
77 // The address before the name: a typo in the URL is the likelier mistake, and
78 // reporting it first means the form does not complain about the name it pre-filled
79 // from an address it has not accepted.
80 let source = ImportSource::new(spec.source.clone())?;
81
82 // A blank name means "call it what the source calls it". The form says so and
83 // offers no JavaScript to fill the box in as the URL is typed, so the fallback is
84 // the whole of that feature — and it lands here rather than in the page so that a
85 // future `/api` cannot answer differently.
86 let name = match spec.name.trim() {
87 "" => source.suggested_name().to_owned(),
88 typed => typed.to_owned(),
89 };
90
91 let mut repo = Repository::new(
92 RepoId::generate(),
93 org.id.clone(),
94 name,
95 None,
96 // Imported repositories are public, matching `Visibility::default()` and the
97 // form, which offers no choice. Making one private is one visit to its settings
98 // page — and the page exists the moment the row does, importing or not.
99 Visibility::Public,
100 SystemTime::now(),
101 )?;
102
103 if stores
104 .repos
105 .find_by_org_and_name(&org.id, &repo.name)
106 .await?
107 .is_some()
108 {
109 return Err(DomainError::AlreadyExists {
110 entity: "repository",
111 }
112 .into());
113 }
114
115 repo.start_import(source);
116 stores.repos.save(&repo).await?;
117
118 Ok(repo)
119}
120
121/// Runs the mirror, then records how it went.
122///
123/// Long-running and spawned, so it takes owned handles rather than borrowing a request.
124/// It reports `Ok(())` for a *failed import* — the failure is data on the repository,
125/// not an error for the caller, which is a background task with nobody to tell. `Err`
126/// is reserved for not being able to record the outcome at all.
127///
128/// **The row is re-read after the clone.** Minutes have passed, and the repository may
129/// have been deleted meanwhile; writing back the copy loaded at the start would
130/// resurrect it as a row pointing at a directory that was removed underneath it.
131pub async fn run_import(
132 id: &RepoId,
133 handle: &OrgName,
134 repos: &impl RepoRepository,
135 importer: &impl GitImport,
136 storage: &impl GitStorage,
137) -> Result<()> {
138 let Some(repo) = repos.find_by_id(id).await? else {
139 return Ok(());
140 };
141
142 let Some(source) = repo.import_source.clone() else {
143 // Only ever reachable if something saved an `importing` row without a source.
144 return Err(DomainError::validation("import source", "is missing").into());
145 };
146
147 let outcome = importer.mirror(handle, &repo.name, &source).await;
148
149 let Some(mut repo) = repos.find_by_id(id).await? else {
150 // Deleted while the clone ran. `delete_repo` removed a directory that did not
151 // exist yet, so what the clone left behind is an orphan and this is the only
152 // code left that knows where it is.
153 let _ = storage.remove(handle, &repo.name).await;
154 return Ok(());
155 };
156
157 match outcome {
158 Ok(()) => {
159 repo.finish_import();
160 // The history landed now, whatever the dates inside it say. The profile
161 // orders by this, and an imported repository has genuinely just arrived.
162 repo.updated_at = SystemTime::now();
163 }
164 Err(error) => {
165 repo.fail_import(explain(error.message()));
166
167 // Whatever the clone left is unusable and must not be browsable: with the
168 // state set to `failed` the page would not read it, but the git transport
169 // and an archive request address the directory, not the row. Best effort —
170 // a directory that will not delete is not a reason to lose the message.
171 if let Err(removal) = storage.remove(handle, &repo.name).await {
172 eprintln!(
173 "steid: import of {handle}/{} failed, and its partial clone could not be removed: {removal}",
174 repo.name
175 );
176 }
177 }
178 }
179
180 repos.save(&repo).await?;
181
182 Ok(())
183}
184
185/// Starts a failed import over, from the address the row still holds.
186///
187/// Owner only, through [`changeable_repo`] — the same predicate that guards renaming and
188/// archiving, because this writes to the repository's directory just as an import does.
189///
190/// **Only a failed import can be retried.** A running one has a task behind it and a
191/// second clone into the same directory would fight it; a repository that was never
192/// imported has no address to retry from. Both answer `NotFound`, which is what the
193/// button being absent already told the visitor.
194///
195/// The source is not re-validated. It passed [`ImportSource::new`] when it was typed, and
196/// re-running a rule that may have tightened since would turn "the remote hung up" into a
197/// repository whose only remaining action is delete. The adapter still re-checks what the
198/// host resolves to, which is the half that matters.
199///
200/// Like [`import_repo`], the caller spawns [`run_import`] afterwards.
201pub async fn retry_import(
202 actor: &Actor,
203 handle: &OrgName,
204 name: &RepoName,
205 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
206) -> Result<Repository> {
207 let mut repo = changeable_repo(actor, handle, name, stores).await?;
208
209 let missing = || {
210 Error::Domain(DomainError::NotFound {
211 entity: "failed import",
212 })
213 };
214
215 if repo.import_state != Some(ImportState::Failed) {
216 return Err(missing());
217 }
218
219 let Some(source) = repo.import_source.clone() else {
220 return Err(missing());
221 };
222
223 // Mutated rather than rebuilt through `Repository::new()`, which resets `pinned`,
224 // `archived` and the import trio — the trap `update_repo` fell into during wave 2.
225 repo.start_import(source);
226 stores.repos.save(&repo).await?;
227
228 Ok(repo)
229}
230
231/// Fails every repository still marked `importing`, and reports how many.
232///
233/// **Called once on boot, before the first request is served.** An import lives in a
234/// background task, so a process that stops takes every running import with it and
235/// leaves rows that will never change again. Without this a restart during an import is
236/// a repository stuck showing "importing…" forever, with no way for its owner to tell
237/// that from one that is merely slow.
238///
239/// Safe precisely because of *when* it runs: nothing has been spawned yet, so there is
240/// no live import for it to mark as dead.
241pub async fn sweep_interrupted_imports(repos: &impl RepoRepository) -> Result<u64> {
242 Ok(repos.fail_interrupted_imports(INTERRUPTED).await?)
243}
244
245/// Turns git's own words into something the owner can act on.
246///
247/// One translation, and it earns its place because it is the **most likely failure
248/// there is**: a typo'd path or a private repository. GitHub answers both with a
249/// challenge rather than a 404, so with `GIT_TERMINAL_PROMPT=0` git reports "could not
250/// read Username", which describes Steid's configuration rather than the mistake.
251/// Every other message is git's, untranslated — inventing a friendlier wording for a
252/// failure nobody anticipated is how a real reason gets hidden.
253fn explain(message: &str) -> String {
254 const PROMPTED: &str = "could not read Username";
255 const AUTHENTICATION: &str = "Authentication failed";
256
257 if message.contains(PROMPTED) || message.contains(AUTHENTICATION) {
258 return "The repository could not be found. It may be private, or the URL may be \
259 wrong — Steid imports public repositories only, and never sends \
260 credentials."
261 .to_owned();
262 }
263
264 truncate(message, MAX_ERROR_LEN)
265}
266
267/// Cuts a message to `limit` bytes on a character boundary, marking that it was cut.
268fn truncate(message: &str, limit: usize) -> String {
269 let message = message.trim();
270
271 if message.len() <= limit {
272 return message.to_owned();
273 }
274
275 let mut cut = limit;
276 while cut > 0 && !message.is_char_boundary(cut) {
277 cut -= 1;
278 }
279
280 format!("{}…", &message[..cut])
281}
282
283/// Turns a use case failure into something the import form can say.
284///
285/// Here rather than in the page so that `/api` and the form cannot disagree about what
286/// a refused address sounds like.
287pub fn import_failure_message(error: &Error, typed_name: &str) -> Option<String> {
288 match error {
289 Error::Domain(DomainError::Validation { field, reason }) => {
290 Some(format!("That {field} is no good: {reason}."))
291 }
292 Error::Domain(DomainError::AlreadyExists { .. }) => Some(format!(
293 "You already have a repository called {}.",
294 typed_name.trim()
295 )),
296 _ => None,
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::{
304 domain::{
305 ImportState, Membership, MembershipId, OrgId, Organization, RepoName, Role, UserId,
306 repository::RepoRepository,
307 },
308 infrastructure::{
309 git::{InMemoryGitImport, InMemoryGitStorage},
310 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
311 },
312 };
313
314 struct Fixture {
315 orgs: InMemoryOrgRepo,
316 memberships: InMemoryMembershipRepo,
317 repos: InMemoryRepoRepo,
318 storage: InMemoryGitStorage,
319 owner: Actor,
320 member: Actor,
321 stranger: Actor,
322 handle: OrgName,
323 }
324
325 async fn fixture() -> Fixture {
326 let orgs = InMemoryOrgRepo::new();
327 let memberships = InMemoryMembershipRepo::new();
328
329 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
330 orgs.save(&org).await.expect("save org");
331
332 let owner = UserId::generate();
333 let member = UserId::generate();
334
335 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
336 memberships
337 .save(&Membership::new(
338 MembershipId::generate(),
339 org.id.clone(),
340 user.clone(),
341 role,
342 ))
343 .await
344 .expect("save membership");
345 }
346
347 Fixture {
348 orgs,
349 memberships,
350 repos: InMemoryRepoRepo::new(),
351 storage: InMemoryGitStorage::new(),
352 owner: Actor::User(owner),
353 member: Actor::User(member),
354 stranger: Actor::User(UserId::generate()),
355 handle: org.name,
356 }
357 }
358
359 impl Fixture {
360 async fn import(&self, actor: &Actor, name: &str, source: &str) -> Result<Repository> {
361 import_repo(
362 actor,
363 &self.handle,
364 &NewImport {
365 name: name.to_owned(),
366 source: source.to_owned(),
367 },
368 &Stores::new(&self.orgs, &self.memberships, &self.repos),
369 )
370 .await
371 }
372
373 async fn run(&self, id: &RepoId, importer: &InMemoryGitImport) -> Result<()> {
374 run_import(id, &self.handle, &self.repos, importer, &self.storage).await
375 }
376
377 async fn reload(&self, id: &RepoId) -> Repository {
378 self.repos
379 .find_by_id(id)
380 .await
381 .expect("lookup")
382 .expect("still there")
383 }
384 }
385
386 const SOURCE: &str = "https://example.com/thing.git";
387
388 #[tokio::test]
389 async fn an_owner_starts_an_import_and_the_row_says_so() {
390 let fixture = fixture().await;
391
392 let repo = fixture
393 .import(&fixture.owner, "thing", SOURCE)
394 .await
395 .expect("should start");
396
397 assert_eq!(repo.import_state, Some(ImportState::Importing));
398 assert_eq!(
399 repo.import_source.as_ref().map(ImportSource::as_str),
400 Some(SOURCE)
401 );
402 assert_eq!(repo.import_error, None);
403 // The row is what the background task and the page both read, so it has to be
404 // saved before this returns rather than by the task that has not started.
405 assert!(
406 fixture
407 .repos
408 .find_by_id(&repo.id)
409 .await
410 .expect("lookup")
411 .is_some()
412 );
413 }
414
415 #[tokio::test]
416 async fn a_blank_name_is_taken_from_the_url() {
417 let fixture = fixture().await;
418
419 let repo = fixture
420 .import(
421 &fixture.owner,
422 " ",
423 "https://github.com/rust-lang/mdBook.git",
424 )
425 .await
426 .expect("should start");
427
428 // Lowercased on the way through `RepoName`, like any other name.
429 assert_eq!(repo.name.as_str(), "mdbook");
430 }
431
432 #[tokio::test]
433 async fn a_url_with_no_path_and_no_name_is_a_validation_failure() {
434 let fixture = fixture().await;
435
436 let error = fixture
437 .import(&fixture.owner, "", "https://example.com")
438 .await
439 .expect_err("nothing to call it");
440
441 assert!(matches!(
442 error,
443 Error::Domain(DomainError::Validation { .. })
444 ));
445 }
446
447 #[tokio::test]
448 async fn only_an_owner_may_import() {
449 let fixture = fixture().await;
450
451 for actor in [&fixture.member, &fixture.stranger, &Actor::Anonymous] {
452 let error = fixture
453 .import(actor, "thing", SOURCE)
454 .await
455 .expect_err("should refuse");
456
457 assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
458 }
459
460 // Nothing was recorded for any of them.
461 assert!(
462 fixture
463 .repos
464 .find_by_org_and_name(
465 &fixture
466 .orgs
467 .find_by_name(&fixture.handle)
468 .await
469 .expect("lookup")
470 .expect("org")
471 .id,
472 &RepoName::new("thing").expect("valid"),
473 )
474 .await
475 .expect("lookup")
476 .is_none()
477 );
478 }
479
480 #[tokio::test]
481 async fn a_source_that_is_not_a_public_https_url_is_refused_before_anything_is_written() {
482 let fixture = fixture().await;
483
484 for source in [
485 "http://example.com/thing.git",
486 "file:///etc/passwd",
487 "https://127.0.0.1/thing.git",
488 "https://169.254.169.254/latest/meta-data",
489 "",
490 ] {
491 let error = fixture
492 .import(&fixture.owner, "thing", source)
493 .await
494 .expect_err("should refuse");
495
496 assert!(
497 matches!(error, Error::Domain(DomainError::Validation { .. })),
498 "expected {source:?} to be a validation failure"
499 );
500 }
501 }
502
503 #[tokio::test]
504 async fn an_import_cannot_take_a_name_that_is_already_used() {
505 let fixture = fixture().await;
506 fixture
507 .import(&fixture.owner, "thing", SOURCE)
508 .await
509 .expect("first");
510
511 let error = fixture
512 .import(&fixture.owner, "thing", SOURCE)
513 .await
514 .expect_err("should refuse");
515
516 assert!(matches!(
517 error,
518 Error::Domain(DomainError::AlreadyExists { .. })
519 ));
520 }
521
522 #[tokio::test]
523 async fn a_finished_import_becomes_an_ordinary_repository() {
524 let fixture = fixture().await;
525 let repo = fixture
526 .import(&fixture.owner, "thing", SOURCE)
527 .await
528 .expect("start");
529
530 let importer = InMemoryGitImport::new();
531 fixture.run(&repo.id, &importer).await.expect("run");
532
533 let after = fixture.reload(&repo.id).await;
534
535 assert_eq!(after.import_state, None);
536 assert!(!after.is_importing());
537 assert_eq!(after.import_error, None);
538 // Provenance survives; the repository is still one that came from elsewhere.
539 assert_eq!(
540 after.import_source.as_ref().map(ImportSource::as_str),
541 Some(SOURCE)
542 );
543 assert_eq!(
544 importer.requests(),
545 vec![(
546 fixture.handle.to_string(),
547 "thing".to_owned(),
548 SOURCE.to_owned()
549 )]
550 );
551 }
552
553 #[tokio::test]
554 async fn a_failed_import_records_the_reason_and_removes_what_it_left() {
555 let fixture = fixture().await;
556 let repo = fixture
557 .import(&fixture.owner, "thing", SOURCE)
558 .await
559 .expect("start");
560
561 // The clone would have created the directory itself, so the fake is seeded with
562 // it to prove the cleanup runs.
563 fixture
564 .storage
565 .init_bare(&fixture.handle, &repo.name)
566 .await
567 .expect("seed a partial clone");
568
569 let importer = InMemoryGitImport::failing("repository not found");
570 fixture.run(&repo.id, &importer).await.expect("run");
571
572 let after = fixture.reload(&repo.id).await;
573
574 assert_eq!(after.import_state, Some(ImportState::Failed));
575 assert_eq!(after.import_error.as_deref(), Some("repository not found"));
576 assert!(
577 !fixture.storage.contains(&fixture.handle, &repo.name),
578 "a half-clone must not stay browsable over the git transport"
579 );
580 }
581
582 #[tokio::test]
583 async fn a_repository_deleted_while_it_imported_is_not_resurrected() {
584 let fixture = fixture().await;
585 let repo = fixture
586 .import(&fixture.owner, "thing", SOURCE)
587 .await
588 .expect("start");
589
590 // What `delete_repo` does, mid-clone.
591 fixture.repos.delete(&repo.id).await.expect("delete");
592
593 let importer = InMemoryGitImport::new();
594 fixture.run(&repo.id, &importer).await.expect("run");
595
596 assert!(
597 fixture
598 .repos
599 .find_by_id(&repo.id)
600 .await
601 .expect("lookup")
602 .is_none(),
603 "saving the copy loaded before the clone would bring the row back"
604 );
605 }
606
607 #[tokio::test]
608 async fn the_boot_sweep_fails_a_running_import_and_leaves_everything_else() {
609 let fixture = fixture().await;
610 let running = fixture
611 .import(&fixture.owner, "running", SOURCE)
612 .await
613 .expect("start");
614 let done = fixture
615 .import(&fixture.owner, "done", SOURCE)
616 .await
617 .expect("start");
618
619 let importer = InMemoryGitImport::new();
620 fixture.run(&done.id, &importer).await.expect("run");
621
622 let swept = sweep_interrupted_imports(&fixture.repos)
623 .await
624 .expect("sweep");
625
626 assert_eq!(swept, 1);
627 assert_eq!(
628 fixture.reload(&running.id).await.import_state,
629 Some(ImportState::Failed)
630 );
631 assert_eq!(
632 fixture.reload(&running.id).await.import_error.as_deref(),
633 Some(INTERRUPTED)
634 );
635 assert_eq!(fixture.reload(&done.id).await.import_state, None);
636 }
637
638 #[test]
639 fn a_credential_challenge_is_explained_as_a_missing_repository() {
640 // What GitHub actually answers for a path that is not there, once terminal
641 // prompts are off. Left as git wrote it, it describes Steid's configuration
642 // rather than the mistake.
643 let explained = explain(
644 "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
645 );
646
647 assert!(explained.contains("could not be found"), "{explained}");
648 assert!(
649 explained.contains("public repositories only"),
650 "{explained}"
651 );
652 }
653
654 #[test]
655 fn every_other_failure_keeps_gits_own_words() {
656 // Inventing a friendlier wording for a failure nobody anticipated is how a real
657 // reason gets hidden.
658 assert_eq!(
659 explain("fatal: unable to access 'https://example.com/': Could not resolve host"),
660 "fatal: unable to access 'https://example.com/': Could not resolve host"
661 );
662 }
663
664 #[test]
665 fn a_long_failure_message_is_cut_and_says_so() {
666 let long = "x".repeat(MAX_ERROR_LEN + 50);
667 let cut = truncate(&long, MAX_ERROR_LEN);
668
669 assert!(cut.len() <= MAX_ERROR_LEN + '…'.len_utf8());
670 assert!(cut.ends_with('…'));
671 }
672
673 #[test]
674 fn cutting_a_message_never_splits_a_character() {
675 // A multi-byte character straddling the limit would panic on a byte slice.
676 let long = "é".repeat(MAX_ERROR_LEN);
677
678 let cut = truncate(&long, MAX_ERROR_LEN);
679
680 assert!(cut.ends_with('…'));
681 }
682
683 #[test]
684 fn a_short_message_is_left_alone() {
685 assert_eq!(truncate(" not found ", MAX_ERROR_LEN), "not found");
686 }
687
688 // --- retrying ---------------------------------------------------------------------
689
690 #[tokio::test]
691 async fn a_failed_import_can_be_started_again_from_the_address_it_kept() {
692 let fixture = fixture().await;
693 let repo = fixture
694 .import(&fixture.owner, "thing", SOURCE)
695 .await
696 .expect("should start");
697
698 fixture
699 .run(
700 &repo.id,
701 &InMemoryGitImport::failing("repository not found"),
702 )
703 .await
704 .expect("should record the failure");
705
706 let retried = retry_import(
707 &fixture.owner,
708 &fixture.handle,
709 &repo.name,
710 &Stores::new(&fixture.orgs, &fixture.memberships, &fixture.repos),
711 )
712 .await
713 .expect("should retry");
714
715 assert_eq!(retried.import_state, Some(ImportState::Importing));
716 assert_eq!(retried.import_error, None, "the old reason is cleared");
717 assert_eq!(
718 retried.import_source.as_ref().map(ImportSource::as_str),
719 Some(SOURCE)
720 );
721
722 // And the second run lands, leaving an ordinary repository behind.
723 fixture
724 .run(&repo.id, &InMemoryGitImport::new())
725 .await
726 .expect("should run");
727
728 assert_eq!(fixture.reload(&repo.id).await.import_state, None);
729 }
730
731 #[tokio::test]
732 async fn only_a_failed_import_is_retryable() {
733 let fixture = fixture().await;
734
735 // Still running: a second clone would fight the first for the directory.
736 let running = fixture
737 .import(&fixture.owner, "running", SOURCE)
738 .await
739 .expect("should start");
740
741 assert!(
742 retry_import(
743 &fixture.owner,
744 &fixture.handle,
745 &running.name,
746 &Stores::new(&fixture.orgs, &fixture.memberships, &fixture.repos),
747 )
748 .await
749 .is_err()
750 );
751
752 // Never imported: there is no address to retry from.
753 let plain = Repository::new(
754 RepoId::generate(),
755 fixture
756 .orgs
757 .find_by_name(&fixture.handle)
758 .await
759 .expect("lookup")
760 .expect("org")
761 .id,
762 "plain",
763 None,
764 Visibility::Public,
765 SystemTime::UNIX_EPOCH,
766 )
767 .expect("valid repo");
768 fixture.repos.save(&plain).await.expect("save");
769
770 assert!(
771 retry_import(
772 &fixture.owner,
773 &fixture.handle,
774 &plain.name,
775 &Stores::new(&fixture.orgs, &fixture.memberships, &fixture.repos),
776 )
777 .await
778 .is_err()
779 );
780 }
781
782 #[tokio::test]
783 async fn only_an_owner_may_retry_an_import() {
784 let fixture = fixture().await;
785 let repo = fixture
786 .import(&fixture.owner, "thing", SOURCE)
787 .await
788 .expect("should start");
789
790 fixture
791 .run(&repo.id, &InMemoryGitImport::failing("nope"))
792 .await
793 .expect("should record the failure");
794
795 for actor in [&fixture.member, &fixture.stranger, &Actor::Anonymous] {
796 assert!(
797 retry_import(
798 actor,
799 &fixture.handle,
800 &repo.name,
801 &Stores::new(&fixture.orgs, &fixture.memberships, &fixture.repos),
802 )
803 .await
804 .is_err()
805 );
806 }
807
808 assert_eq!(
809 fixture.reload(&repo.id).await.import_state,
810 Some(ImportState::Failed),
811 "nothing was restarted"
812 );
813 }
814}