steid

@jamesgill /

steid/src/application/browse_tests.rs
Rust29.3 KBCode·Blame·History·Raw
1use std::time::SystemTime;
2
3use super::*;
4use crate::{
5 domain::{
6 EntryKind, Membership, MembershipId, OrgId, Organization, RepoId, Repository, TreeEntry,
7 UserId, Visibility,
8 },
9 infrastructure::{
10 git::InMemoryGitQuery,
11 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
12 },
13};
14
15struct Fixture {
16 orgs: InMemoryOrgRepo,
17 memberships: InMemoryMembershipRepo,
18 repos: InMemoryRepoRepo,
19 handle: OrgName,
20 owner: Actor,
21 stranger: Actor,
22}
23
24/// One organisation with an owner, and a `steid` repository of the given visibility.
25async fn fixture(visibility: Visibility) -> Fixture {
26 let orgs = InMemoryOrgRepo::new();
27 let memberships = InMemoryMembershipRepo::new();
28 let repos = InMemoryRepoRepo::new();
29
30 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
31 orgs.save(&org).await.expect("save org");
32
33 let owner = UserId::generate();
34 memberships
35 .save(&Membership::new(
36 MembershipId::generate(),
37 org.id.clone(),
38 owner.clone(),
39 crate::domain::Role::Owner,
40 ))
41 .await
42 .expect("save membership");
43
44 repos
45 .save(
46 &Repository::new(
47 RepoId::generate(),
48 org.id.clone(),
49 "steid",
50 None,
51 visibility,
52 SystemTime::now(),
53 )
54 .expect("valid repository"),
55 )
56 .await
57 .expect("save repo");
58
59 Fixture {
60 orgs,
61 memberships,
62 repos,
63 handle: org.name,
64 owner: Actor::User(owner),
65 stranger: Actor::Anonymous,
66 }
67}
68
69fn repo_name() -> RepoName {
70 RepoName::new("steid").expect("valid repository name")
71}
72
73fn rev(value: &str) -> RefName {
74 RefName::new(value).expect("valid revision")
75}
76
77fn path(value: &str) -> RepoPath {
78 RepoPath::new(value).expect("valid path")
79}
80
81impl Fixture {
82 async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
83 list_refs(
84 &self.handle,
85 &repo_name(),
86 actor,
87 &Stores::new(&self.orgs, &self.memberships, &self.repos),
88 queries,
89 )
90 .await
91 }
92
93 async fn raw(
94 &self,
95 actor: &Actor,
96 path: &RepoPath,
97 queries: &InMemoryGitQuery,
98 ) -> Result<Option<RawFile>> {
99 read_raw_file(
100 &self.handle,
101 &repo_name(),
102 Some(&rev("main")),
103 path,
104 actor,
105 &Stores::new(&self.orgs, &self.memberships, &self.repos),
106 queries,
107 )
108 .await
109 }
110}
111
112// --- repo_log -------------------------------------------------------------------
113
114/// `count` commits, newest first, each identifiable by its summary.
115fn history(count: usize) -> Vec<CommitSummary> {
116 (0..count)
117 .map(|n| CommitSummary {
118 id: ObjectId::from_trusted(format!("{n:040x}")),
119 summary: format!("commit {n}"),
120 author_name: "Ada Lovelace".to_owned(),
121 committed_at: at(n as u64),
122 })
123 .collect()
124}
125
126async fn log_page(f: &Fixture, queries: &InMemoryGitQuery, page: usize) -> Paged<CommitSummary> {
127 repo_log(
128 &f.handle,
129 &repo_name(),
130 Some(&rev("main")),
131 page,
132 &f.owner,
133 &Stores::new(&f.orgs, &f.memberships, &f.repos),
134 queries,
135 )
136 .await
137 .expect("should read")
138 .expect("visible")
139}
140
141#[tokio::test]
142async fn the_second_page_of_a_log_starts_where_the_first_ended() {
143 let f = fixture(Visibility::Public).await;
144 let queries = InMemoryGitQuery::new().with_log(history(LOG_LIMIT + 10));
145
146 let first = log_page(&f, &queries, 1).await;
147 let second = log_page(&f, &queries, 2).await;
148
149 assert_eq!(first.items.len(), LOG_LIMIT);
150 assert_eq!(second.items.len(), 10);
151 assert_eq!(second.offset(), LOG_LIMIT);
152 assert_eq!(second.items[0].summary, format!("commit {LOG_LIMIT}"));
153 assert_eq!(second.pages, 2);
154 assert_eq!(second.total, LOG_LIMIT + 10);
155}
156
157#[tokio::test]
158async fn a_log_page_past_the_end_clamps_to_the_last_one() {
159 // The shape a link into a rewritten branch takes: the page number is still in
160 // the URL, the history behind it is not.
161 let f = fixture(Visibility::Public).await;
162 let queries = InMemoryGitQuery::new().with_log(history(LOG_LIMIT + 10));
163
164 let page = log_page(&f, &queries, 400).await;
165
166 assert_eq!(page.page, 2);
167 assert_eq!(page.items.len(), 10);
168}
169
170#[tokio::test]
171async fn a_log_that_fits_on_one_page_is_not_paged() {
172 let f = fixture(Visibility::Public).await;
173 let queries = InMemoryGitQuery::new().with_log(history(3));
174
175 let page = log_page(&f, &queries, 1).await;
176
177 assert!(!page.is_paged());
178 assert_eq!(page.total, 3);
179}
180
181#[tokio::test]
182async fn an_empty_repository_logs_one_empty_page() {
183 let f = fixture(Visibility::Public).await;
184 let queries = InMemoryGitQuery::empty();
185
186 let page = repo_log(
187 &f.handle,
188 &repo_name(),
189 None,
190 1,
191 &f.owner,
192 &Stores::new(&f.orgs, &f.memberships, &f.repos),
193 &queries,
194 )
195 .await
196 .expect("should read")
197 .expect("visible");
198
199 assert!(page.items.is_empty());
200 assert!(!page.is_paged());
201}
202
203#[tokio::test]
204async fn a_log_is_invisible_to_a_stranger_when_the_repository_is() {
205 let f = fixture(Visibility::Private).await;
206 let queries = InMemoryGitQuery::new().with_log(history(3));
207
208 let seen = repo_log(
209 &f.handle,
210 &repo_name(),
211 None,
212 1,
213 &f.stranger,
214 &Stores::new(&f.orgs, &f.memberships, &f.repos),
215 &queries,
216 )
217 .await
218 .expect("should read");
219
220 assert!(seen.is_none());
221}
222
223// --- submodules and symlinks ----------------------------------------------------
224
225/// What a listing at `at` resolves to for the owner.
226async fn browsed(f: &Fixture, queries: &InMemoryGitQuery, at: &str) -> Option<Browsed> {
227 browse_repo(
228 &f.handle,
229 &repo_name(),
230 Some(&rev("main")),
231 &path(at),
232 &f.owner,
233 &Stores::new(&f.orgs, &f.memberships, &f.repos),
234 queries,
235 )
236 .await
237 .expect("should read")
238}
239
240#[test]
241fn a_gitmodules_gives_each_submodules_path_and_url() {
242 let found = parse_gitmodules(
243 "[submodule \"topcoat\"]\n\
244 \tpath = vendor/topcoat\n\
245 \turl = https://github.com/tokio-rs/topcoat.git\n\
246 [submodule \"docs\"]\n\
247 \tpath = docs/theme\n\
248 \turl = https://example.com/theme\n",
249 );
250
251 assert_eq!(
252 found,
253 [
254 (
255 "vendor/topcoat".to_owned(),
256 "https://github.com/tokio-rs/topcoat.git".to_owned()
257 ),
258 (
259 "docs/theme".to_owned(),
260 "https://example.com/theme".to_owned()
261 ),
262 ]
263 );
264}
265
266#[test]
267fn a_url_a_browser_cannot_open_is_not_offered_as_a_link() {
268 // All three are legitimate in a real `.gitmodules` and none of them is a page.
269 for url in [
270 "git@github.com:ada/steid.git",
271 "ssh://git@example.com/ada/steid",
272 "../sibling",
273 "http://example.com/insecure",
274 ] {
275 let text = format!("[submodule \"x\"]\npath = vendor/x\nurl = {url}\n");
276
277 assert!(
278 parse_gitmodules(&text).is_empty(),
279 "{url} should not become a link"
280 );
281 }
282}
283
284#[test]
285fn a_gitmodules_survives_comments_quotes_and_a_missing_half() {
286 let found = parse_gitmodules(
287 "# a comment\n\
288 ; another\n\
289 [submodule \"quoted\"]\n\
290 path = \"vendor/quoted\"\n\
291 url = \"https://example.com/quoted\"\n\
292 [submodule \"no-url\"]\n\
293 path = vendor/orphan\n\
294 [submodule \"no-path\"]\n\
295 url = https://example.com/orphan\n",
296 );
297
298 // A section missing either half contributes nothing rather than pairing itself
299 // with a neighbour's value.
300 assert_eq!(
301 found,
302 [(
303 "vendor/quoted".to_owned(),
304 "https://example.com/quoted".to_owned()
305 )]
306 );
307}
308
309#[test]
310fn a_relative_target_resolves_against_the_directory_it_is_written_in() {
311 let at = |dir: &str, target: &str| {
312 resolve_relative(&RepoPath::from_trusted(dir), target).map(|path| path.as_str().to_owned())
313 };
314
315 assert_eq!(at("src", "main.rs").as_deref(), Some("src/main.rs"));
316 assert_eq!(at("src", "./main.rs").as_deref(), Some("src/main.rs"));
317 assert_eq!(
318 at("src/web", "../domain/id.rs").as_deref(),
319 Some("src/domain/id.rs")
320 );
321 assert_eq!(at("", "README.md").as_deref(), Some("README.md"));
322 assert_eq!(
323 at("docs/a/b", "../../../LICENSE").as_deref(),
324 Some("LICENSE")
325 );
326}
327
328#[test]
329fn a_target_that_leaves_the_repository_resolves_to_nothing() {
330 let at = |dir: &str, target: &str| resolve_relative(&RepoPath::from_trusted(dir), target);
331
332 // Each of these is a link Steid can show and must not follow.
333 assert!(at("src", "../../etc/passwd").is_none());
334 assert!(at("", "/etc/passwd").is_none());
335 assert!(at("src", "..").is_none());
336 assert!(at("src", "").is_none());
337 // A colon is refused by `RepoPath` itself, because git reads it as a revision
338 // separator.
339 assert!(at("src", "weird:name").is_none());
340}
341
342#[tokio::test]
343async fn a_submodule_is_linked_to_the_upstream_gitmodules_names() {
344 let f = fixture(Visibility::Public).await;
345 let queries = InMemoryGitQuery::new()
346 .with_tree(
347 "main",
348 "vendor",
349 vec![TreeEntry {
350 name: "topcoat".to_owned(),
351 kind: EntryKind::Submodule,
352 id: ObjectId::from_trusted("c".repeat(40)),
353 size: None,
354 }],
355 )
356 .with_blob(
357 "main",
358 ".gitmodules",
359 "[submodule \"topcoat\"]\npath = vendor/topcoat\nurl = https://example.com/topcoat\n"
360 .as_bytes()
361 .to_vec(),
362 );
363
364 let Some(Browsed::Directory { details, .. }) = browsed(&f, &queries, "vendor").await else {
365 panic!("a directory");
366 };
367
368 // Keyed by the path from the root, which is what `.gitmodules` records — the
369 // entry's own name is only its last component.
370 assert_eq!(
371 details["topcoat"].url.as_deref(),
372 Some("https://example.com/topcoat")
373 );
374}
375
376#[tokio::test]
377async fn a_symlink_carries_its_target_and_where_that_lands() {
378 let f = fixture(Visibility::Public).await;
379 let inside = ObjectId::from_trusted("d".repeat(40));
380 let outside = ObjectId::from_trusted("e".repeat(40));
381 let queries = InMemoryGitQuery::new()
382 .with_tree(
383 "main",
384 "src",
385 vec![
386 TreeEntry {
387 name: "here".to_owned(),
388 kind: EntryKind::Symlink,
389 id: inside.clone(),
390 size: Some(9),
391 },
392 TreeEntry {
393 name: "elsewhere".to_owned(),
394 kind: EntryKind::Symlink,
395 id: outside.clone(),
396 size: Some(11),
397 },
398 ],
399 )
400 .with_symlink_target(&inside, "../README.md")
401 .with_symlink_target(&outside, "/etc/passwd");
402
403 let Some(Browsed::Directory { details, .. }) = browsed(&f, &queries, "src").await else {
404 panic!("a directory");
405 };
406
407 assert_eq!(details["here"].target.as_deref(), Some("../README.md"));
408 assert_eq!(
409 details["here"].resolved.as_ref().map(RepoPath::as_str),
410 Some("README.md")
411 );
412 // Shown, and not followed: the target is still worth saying.
413 assert_eq!(details["elsewhere"].target.as_deref(), Some("/etc/passwd"));
414 assert!(details["elsewhere"].resolved.is_none());
415}
416
417#[tokio::test]
418async fn an_ordinary_directory_asks_for_no_details_at_all() {
419 let f = fixture(Visibility::Public).await;
420 let queries = InMemoryGitQuery::new().with_tree(
421 "main",
422 "src",
423 vec![TreeEntry {
424 name: "main.rs".to_owned(),
425 kind: EntryKind::Blob,
426 id: ObjectId::from_trusted("a".repeat(40)),
427 size: Some(12),
428 }],
429 );
430
431 let Some(Browsed::Directory { details, .. }) = browsed(&f, &queries, "src").await else {
432 panic!("a directory");
433 };
434
435 assert!(details.is_empty());
436}
437
438// --- repo_path_log --------------------------------------------------------------
439
440async fn path_log(
441 f: &Fixture,
442 queries: &InMemoryGitQuery,
443 at: &str,
444 page: usize,
445) -> Option<PathLog> {
446 repo_path_log(
447 &f.handle,
448 &repo_name(),
449 Some(&rev("main")),
450 &path(at),
451 page,
452 &f.owner,
453 &Stores::new(&f.orgs, &f.memberships, &f.repos),
454 queries,
455 )
456 .await
457 .expect("should read")
458}
459
460/// A repository whose `src` directory holds one file, both with a history.
461fn with_history() -> InMemoryGitQuery {
462 InMemoryGitQuery::new()
463 .with_tree(
464 "main",
465 "src",
466 vec![TreeEntry {
467 name: "main.rs".to_owned(),
468 kind: EntryKind::Blob,
469 id: ObjectId::from_trusted("a".repeat(40)),
470 size: Some(12),
471 }],
472 )
473 .with_blob("main", "src/main.rs", b"fn main() {}".to_vec())
474 .with_path_log("main", "src", history(LOG_LIMIT + 4))
475 .with_path_log("main", "src/main.rs", history(2))
476}
477
478#[tokio::test]
479async fn a_files_history_is_followed_and_a_directorys_is_not() {
480 let f = fixture(Visibility::Public).await;
481 let queries = with_history();
482
483 // The flag the port turns into `--follow`, decided by what is at the path
484 // rather than by the caller guessing.
485 assert!(
486 path_log(&f, &queries, "src/main.rs", 1)
487 .await
488 .expect("found")
489 .is_file
490 );
491 assert!(
492 !path_log(&f, &queries, "src", 1)
493 .await
494 .expect("found")
495 .is_file
496 );
497}
498
499#[tokio::test]
500async fn a_paths_history_pages_like_every_other_list() {
501 let f = fixture(Visibility::Public).await;
502 let queries = with_history();
503
504 let second = path_log(&f, &queries, "src", 2).await.expect("found");
505
506 assert_eq!(second.commits.items.len(), 4);
507 assert_eq!(second.commits.total, LOG_LIMIT + 4);
508 assert_eq!(second.commits.offset(), LOG_LIMIT);
509 // A page past the end clamps rather than 404s, the same as the log's.
510 assert_eq!(
511 path_log(&f, &queries, "src", 400)
512 .await
513 .expect("found")
514 .commits
515 .page,
516 2
517 );
518}
519
520#[tokio::test]
521async fn a_long_followed_history_stops_counting_at_the_cap() {
522 // `--follow` reads a tree per commit, so an uncapped count on a long history is
523 // the read likeliest to time out. Past the cap the total is the cap and the page
524 // says so; the paging control offers exactly the pages that were counted.
525 use crate::infrastructure::git_query::FOLLOWED_LOG_SCAN;
526
527 let f = fixture(Visibility::Public).await;
528 let queries = with_history().with_path_log(
529 "main",
530 "src/main.rs",
531 history(FOLLOWED_LOG_SCAN + LOG_LIMIT),
532 );
533
534 let log = path_log(&f, &queries, "src/main.rs", 1)
535 .await
536 .expect("found");
537
538 assert!(log.capped);
539 assert_eq!(log.commits.total, FOLLOWED_LOG_SCAN);
540 assert_eq!(log.commits.pages, FOLLOWED_LOG_SCAN / LOG_LIMIT);
541
542 // The last page the control offers is a page that was counted, and clamping
543 // lands there rather than on a page beyond the cap.
544 let last = path_log(&f, &queries, "src/main.rs", 999)
545 .await
546 .expect("found");
547 assert_eq!(last.commits.page, FOLLOWED_LOG_SCAN / LOG_LIMIT);
548 assert_eq!(last.commits.items.len(), LOG_LIMIT);
549}
550
551#[tokio::test]
552async fn a_directorys_count_is_exact_because_it_is_not_followed() {
553 let f = fixture(Visibility::Public).await;
554
555 assert!(
556 !path_log(&f, &with_history(), "src", 1)
557 .await
558 .expect("found")
559 .capped
560 );
561}
562
563#[tokio::test]
564async fn a_path_that_is_not_there_has_no_history_page() {
565 // A 404 rather than an empty list: an empty list is what a path that exists and
566 // was never touched would say, and a reader should be able to tell them apart.
567 let f = fixture(Visibility::Public).await;
568
569 assert!(path_log(&f, &with_history(), "nope.rs", 1).await.is_none());
570}
571
572#[tokio::test]
573async fn an_empty_repository_has_no_path_to_have_a_history() {
574 let f = fixture(Visibility::Public).await;
575
576 assert!(
577 path_log(&f, &InMemoryGitQuery::empty(), "src", 1)
578 .await
579 .is_none()
580 );
581}
582
583#[tokio::test]
584async fn a_paths_history_is_invisible_to_a_stranger_when_the_repository_is() {
585 let f = fixture(Visibility::Private).await;
586
587 let seen = repo_path_log(
588 &f.handle,
589 &repo_name(),
590 Some(&rev("main")),
591 &path("src"),
592 1,
593 &f.stranger,
594 &Stores::new(&f.orgs, &f.memberships, &f.repos),
595 &with_history(),
596 )
597 .await
598 .expect("should read");
599
600 assert!(seen.is_none());
601}
602
603// --- list_branches and list_tags ------------------------------------------------
604
605fn at(offset: u64) -> SystemTime {
606 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + offset)
607}
608
609#[tokio::test]
610async fn the_default_branch_is_pinned_above_the_rest() {
611 let f = fixture(Visibility::Public).await;
612 // Seeded in git's order — newest tip first — with the default branch stalest,
613 // which is the case pinning exists for.
614 let queries = InMemoryGitQuery::new()
615 .with_branch_row("feature/login", false, at(200))
616 .with_branch_row("spike", false, at(100))
617 .with_branch_row("main", true, at(0));
618
619 let page = list_branches(
620 &f.handle,
621 &repo_name(),
622 Some(1),
623 &f.owner,
624 &Stores::new(&f.orgs, &f.memberships, &f.repos),
625 &queries,
626 )
627 .await
628 .expect("should read")
629 .expect("visible");
630
631 assert_eq!(
632 page.rows
633 .items
634 .iter()
635 .map(|row| row.name.as_str())
636 .collect::<Vec<_>>(),
637 vec!["main", "feature/login", "spike"]
638 );
639 // Pinning must not disturb the date order of everything below it.
640 assert!(!page.repo_is_empty);
641}
642
643#[tokio::test]
644async fn a_repository_with_no_branches_says_it_is_empty() {
645 // `InMemoryGitQuery::empty()` has no default branch, which is what "nothing has
646 // been pushed" looks like — and what makes the page show the push snippet.
647 let f = fixture(Visibility::Public).await;
648
649 let page = list_branches(
650 &f.handle,
651 &repo_name(),
652 Some(1),
653 &f.owner,
654 &Stores::new(&f.orgs, &f.memberships, &f.repos),
655 &InMemoryGitQuery::empty(),
656 )
657 .await
658 .expect("should read")
659 .expect("visible");
660
661 assert!(page.rows.items.is_empty());
662 assert!(page.repo_is_empty);
663}
664
665#[tokio::test]
666async fn a_repository_with_commits_but_no_tags_is_not_empty() {
667 // The distinction the page needs: "no tags yet" rather than "nothing pushed".
668 let f = fixture(Visibility::Public).await;
669
670 let page = list_tags(
671 &f.handle,
672 &repo_name(),
673 Some(1),
674 &f.owner,
675 &Stores::new(&f.orgs, &f.memberships, &f.repos),
676 &InMemoryGitQuery::new(),
677 )
678 .await
679 .expect("should read")
680 .expect("visible");
681
682 assert!(page.rows.items.is_empty());
683 assert!(!page.repo_is_empty);
684}
685
686#[tokio::test]
687async fn tags_keep_the_order_git_sorted_them_into() {
688 let f = fixture(Visibility::Public).await;
689 let queries = InMemoryGitQuery::new()
690 .with_tag_row("v2.0", true, at(200))
691 .with_tag_row("v0.9", false, at(100));
692
693 let page = list_tags(
694 &f.handle,
695 &repo_name(),
696 Some(1),
697 &f.owner,
698 &Stores::new(&f.orgs, &f.memberships, &f.repos),
699 &queries,
700 )
701 .await
702 .expect("should read")
703 .expect("visible");
704
705 assert_eq!(
706 page.rows
707 .items
708 .iter()
709 .map(|row| row.name.as_str())
710 .collect::<Vec<_>>(),
711 vec!["v2.0", "v0.9"]
712 );
713 assert_eq!(page.rows.items[0].message.as_deref(), Some("release v2.0"));
714 assert_eq!(page.rows.items[1].message, None);
715}
716
717#[tokio::test]
718async fn the_second_page_of_branches_starts_where_the_first_ended() {
719 let f = fixture(Visibility::Public).await;
720 let mut queries = InMemoryGitQuery::new();
721
722 for n in 0..REF_LIMIT + 5 {
723 queries = queries.with_branch_row(&format!("branch-{n:03}"), n == 0, at(n as u64));
724 }
725
726 let first = list_branches(
727 &f.handle,
728 &repo_name(),
729 Some(1),
730 &f.owner,
731 &Stores::new(&f.orgs, &f.memberships, &f.repos),
732 &queries,
733 )
734 .await
735 .expect("should read")
736 .expect("visible");
737 let second = list_branches(
738 &f.handle,
739 &repo_name(),
740 Some(2),
741 &f.owner,
742 &Stores::new(&f.orgs, &f.memberships, &f.repos),
743 &queries,
744 )
745 .await
746 .expect("should read")
747 .expect("visible");
748
749 assert_eq!(first.rows.items.len(), REF_LIMIT);
750 assert_eq!(second.rows.items.len(), 5);
751 assert_eq!(second.rows.offset(), REF_LIMIT);
752 assert_eq!(second.rows.total, REF_LIMIT + 5);
753 assert_eq!(first.rows.items[REF_LIMIT - 1].name.as_str(), "branch-099");
754 assert_eq!(
755 second.rows.items[0].name.as_str(),
756 "branch-100",
757 "the second page starts where the first ended"
758 );
759}
760
761#[tokio::test]
762async fn no_page_number_means_every_branch() {
763 // What the settings form's default-branch `<select>` asks for: an option
764 // missing from it is a branch nobody can choose.
765 let f = fixture(Visibility::Public).await;
766 let mut queries = InMemoryGitQuery::new();
767
768 for n in 0..REF_LIMIT + 5 {
769 queries = queries.with_branch_row(&format!("branch-{n:03}"), n == 0, at(n as u64));
770 }
771
772 let all = list_branches(
773 &f.handle,
774 &repo_name(),
775 None,
776 &f.owner,
777 &Stores::new(&f.orgs, &f.memberships, &f.repos),
778 &queries,
779 )
780 .await
781 .expect("should read")
782 .expect("visible");
783
784 assert_eq!(all.rows.items.len(), REF_LIMIT + 5);
785 assert!(!all.rows.is_paged());
786}
787
788#[tokio::test]
789async fn a_private_repositorys_branches_and_tags_are_invisible_to_a_stranger() {
790 // A branch name is content, the same way the switcher's list is.
791 let f = fixture(Visibility::Private).await;
792 let queries = InMemoryGitQuery::new()
793 .with_branch_row("secret-work", true, at(0))
794 .with_tag_row("unreleased", true, at(0));
795
796 assert!(
797 list_branches(
798 &f.handle,
799 &repo_name(),
800 Some(1),
801 &f.stranger,
802 &Stores::new(&f.orgs, &f.memberships, &f.repos),
803 &queries,
804 )
805 .await
806 .expect("should read")
807 .is_none()
808 );
809 assert!(
810 list_tags(
811 &f.handle,
812 &repo_name(),
813 Some(1),
814 &f.stranger,
815 &Stores::new(&f.orgs, &f.memberships, &f.repos),
816 &queries,
817 )
818 .await
819 .expect("should read")
820 .is_none()
821 );
822}
823
824// --- list_refs ----------------------------------------------------------------
825
826#[tokio::test]
827async fn branches_and_tags_come_back_separated_and_ordered() {
828 let f = fixture(Visibility::Public).await;
829 let queries = InMemoryGitQuery::new()
830 .with_branch("main")
831 .with_branch("Feature")
832 .with_tag("v2.0")
833 .with_tag("v1.0");
834
835 let refs = f
836 .refs(&f.owner, &queries)
837 .await
838 .expect("should read")
839 .expect("visible");
840
841 // Case-insensitively, so `Feature` sorts next to `feature` rather than before
842 // every lowercase name.
843 assert_eq!(
844 refs.branches
845 .iter()
846 .map(RefName::as_str)
847 .collect::<Vec<_>>(),
848 vec!["Feature", "main"]
849 );
850 assert_eq!(
851 refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
852 vec!["v1.0", "v2.0"]
853 );
854}
855
856#[tokio::test]
857async fn an_empty_repository_has_no_refs_to_switch_between() {
858 let f = fixture(Visibility::Public).await;
859
860 let refs = f
861 .refs(&f.owner, &InMemoryGitQuery::empty())
862 .await
863 .expect("should read")
864 .expect("visible");
865
866 assert!(refs.is_empty());
867}
868
869#[tokio::test]
870async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
871 // The ref list names branches, which are content. Same answer as the page.
872 let f = fixture(Visibility::Private).await;
873 let queries = InMemoryGitQuery::new().with_branch("secret-work");
874
875 assert!(
876 f.refs(&f.stranger, &queries)
877 .await
878 .expect("should read")
879 .is_none()
880 );
881 assert!(
882 f.refs(&f.owner, &queries)
883 .await
884 .expect("should read")
885 .is_some()
886 );
887}
888
889#[test]
890fn a_ref_list_knows_the_revision_it_is_showing() {
891 let refs = RefList {
892 branches: vec![RefName::from_trusted("main")],
893 tags: vec![RefName::from_trusted("v1.0")],
894 };
895
896 assert!(refs.contains(&rev("main")));
897 assert!(refs.contains(&rev("v1.0")));
898 // An object id is not a ref, which is what a switcher needs to know before it
899 // tries to highlight one.
900 assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
901}
902
903// --- read_raw_file ------------------------------------------------------------
904
905#[tokio::test]
906async fn a_text_file_comes_back_with_its_own_name() {
907 let f = fixture(Visibility::Public).await;
908 let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
909
910 let raw = f
911 .raw(&f.owner, &path("src/main.rs"), &queries)
912 .await
913 .expect("should read")
914 .expect("found");
915
916 assert_eq!(
917 raw,
918 RawFile::Ready {
919 name: "main.rs".to_owned(),
920 content: b"fn main() {}\n".to_vec(),
921 }
922 );
923}
924
925#[tokio::test]
926async fn a_binary_file_comes_back_byte_for_byte() {
927 // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
928 let f = fixture(Visibility::Public).await;
929 let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
930 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
931
932 let raw = f
933 .raw(&f.owner, &path("logo.png"), &queries)
934 .await
935 .expect("should read")
936 .expect("found");
937
938 match raw {
939 RawFile::Ready { content, .. } => assert_eq!(content, bytes),
940 other => panic!("expected the bytes, got {other:?}"),
941 }
942}
943
944#[tokio::test]
945async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
946 let f = fixture(Visibility::Public).await;
947 let size = MAX_RAW_BYTES as usize + 1;
948 let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
949
950 let raw = f
951 .raw(&f.owner, &path("huge.bin"), &queries)
952 .await
953 .expect("should read")
954 .expect("found");
955
956 assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
957}
958
959#[tokio::test]
960async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
961 // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
962 // these bytes in a browser, so the reason for the page's limit does not apply.
963 let f = fixture(Visibility::Public).await;
964 let size = MAX_BLOB_BYTES as usize + 1;
965 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
966
967 let raw = f
968 .raw(&f.owner, &path("big.txt"), &queries)
969 .await
970 .expect("should read")
971 .expect("found");
972
973 match raw {
974 RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
975 other => panic!("expected the bytes, got {other:?}"),
976 }
977}
978
979#[tokio::test]
980async fn a_directory_has_no_raw_bytes() {
981 // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
982 // not a file, and there is nothing to serve.
983 let f = fixture(Visibility::Public).await;
984 let queries = InMemoryGitQuery::new()
985 .with_tree("main", "src", Vec::new())
986 .with_blob("main", "src/main.rs", b"fn main() {}\n");
987
988 assert!(
989 f.raw(&f.owner, &path("src"), &queries)
990 .await
991 .expect("should read")
992 .is_none()
993 );
994}
995
996#[tokio::test]
997async fn a_path_that_is_not_there_is_not_found() {
998 let f = fixture(Visibility::Public).await;
999 let queries = InMemoryGitQuery::new();
1000
1001 assert!(
1002 f.raw(&f.owner, &path("nope.txt"), &queries)
1003 .await
1004 .expect("should read")
1005 .is_none()
1006 );
1007}
1008
1009#[tokio::test]
1010async fn an_empty_repository_serves_nothing_raw() {
1011 let f = fixture(Visibility::Public).await;
1012
1013 assert!(
1014 f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
1015 .await
1016 .expect("should read")
1017 .is_none()
1018 );
1019}
1020
1021#[tokio::test]
1022async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
1023 // The point of the endpoint's authorization: a raw URL is not a way around the
1024 // page's answer.
1025 let f = fixture(Visibility::Private).await;
1026 let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
1027
1028 assert!(
1029 f.raw(&f.stranger, &path("secret.txt"), &queries)
1030 .await
1031 .expect("should read")
1032 .is_none()
1033 );
1034 assert!(
1035 f.raw(&f.owner, &path("secret.txt"), &queries)
1036 .await
1037 .expect("should read")
1038 .is_some()
1039 );
1040}