Skip to main content

max / makenotwork

37.7 KB · 1286 lines History Blame Raw
1 //! Git browser route tests: repo overview, tree, file, commits, raw, 404s.
2 //!
3 //! Creates temp bare repos with gitoxide to test the actual HTTP routes.
4
5 use crate::harness::TestHarness;
6
7 /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with two commits on "main".
8 /// Commit 1 (root): README.md, src/main.rs
9 /// Commit 2: modifies src/main.rs (adds a line)
10 fn make_test_repo(dir: &std::path::Path) {
11 use crate::harness::gitfixture::{blob, commit, init_bare, tree};
12 use gix::objs::tree::EntryKind;
13
14 let repo = init_bare(dir, "testowner", "testrepo");
15
16 // Commit 1: README.md + src/main.rs
17 let readme = blob(&repo, b"# Test Repo\n\nHello world.");
18 let main_rs = blob(&repo, b"fn main() {\n println!(\"hello\");\n}\n");
19 let src = tree(&repo, &[("main.rs", main_rs, EntryKind::Blob)]);
20 let root = tree(
21 &repo,
22 &[
23 ("README.md", readme, EntryKind::Blob),
24 ("src", src, EntryKind::Tree),
25 ],
26 );
27 let first = commit(&repo, "Initial commit", root, Vec::new());
28
29 // Commit 2: modify src/main.rs
30 let main_rs_v2 = blob(
31 &repo,
32 b"fn main() {\n println!(\"hello\");\n println!(\"world\");\n}\n",
33 );
34 let src2 = tree(&repo, &[("main.rs", main_rs_v2, EntryKind::Blob)]);
35 let root2 = tree(
36 &repo,
37 &[
38 ("README.md", readme, EntryKind::Blob),
39 ("src", src2, EntryKind::Tree),
40 ],
41 );
42 commit(&repo, "Add world output", root2, vec![first]);
43 }
44
45 /// Set up a harness with git repos and a user matching the disk owner.
46 async fn setup_git_harness(tmp: &tempfile::TempDir) -> TestHarness {
47 let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
48 // Create a user whose username matches the disk directory
49 h.signup("testowner", "testowner@example.com", "password123")
50 .await;
51 h
52 }
53
54 // ── 404 when git not configured ──
55
56 #[tokio::test]
57 async fn git_repo_returns_404_when_not_configured() {
58 let mut h = TestHarness::new().await;
59
60 let resp = h.client.get("/git/owner/repo").await;
61 assert_eq!(resp.status, 404, "No git_repos_path → 404");
62 }
63
64 // ── Repo overview ──
65
66 #[tokio::test]
67 async fn git_repo_overview() {
68 let tmp = tempfile::TempDir::new().unwrap();
69 make_test_repo(tmp.path());
70 let mut h = setup_git_harness(&tmp).await;
71
72 let resp = h.client.get("/git/testowner/testrepo").await;
73 assert_eq!(
74 resp.status, 200,
75 "Repo overview failed: {} {}",
76 resp.status, resp.text
77 );
78 // HTML should contain the repo name and README content
79 assert!(resp.text.contains("testrepo"), "Should show repo name");
80 assert!(resp.text.contains("Test Repo"), "Should render README");
81 }
82
83 // ── Nonexistent repo ──
84
85 #[tokio::test]
86 async fn git_nonexistent_repo_returns_404() {
87 let tmp = tempfile::TempDir::new().unwrap();
88 make_test_repo(tmp.path());
89 let mut h = setup_git_harness(&tmp).await;
90
91 let resp = h.client.get("/git/testowner/nope").await;
92 assert_eq!(resp.status, 404);
93 }
94
95 // ── Tree at ref ──
96
97 #[tokio::test]
98 async fn git_tree_at_ref() {
99 let tmp = tempfile::TempDir::new().unwrap();
100 make_test_repo(tmp.path());
101 let mut h = setup_git_harness(&tmp).await;
102
103 let resp = h.client.get("/git/testowner/testrepo/tree/main").await;
104 assert_eq!(
105 resp.status, 200,
106 "Tree at ref failed: {} {}",
107 resp.status, resp.text
108 );
109 // Should list files: README.md and src/
110 assert!(resp.text.contains("README.md"), "Should show README.md");
111 assert!(resp.text.contains("src"), "Should show src directory");
112 }
113
114 // ── Subdirectory ──
115
116 #[tokio::test]
117 async fn git_tree_subdirectory() {
118 let tmp = tempfile::TempDir::new().unwrap();
119 make_test_repo(tmp.path());
120 let mut h = setup_git_harness(&tmp).await;
121
122 let resp = h.client.get("/git/testowner/testrepo/tree/main/src").await;
123 assert_eq!(
124 resp.status, 200,
125 "Subdirectory failed: {} {}",
126 resp.status, resp.text
127 );
128 assert!(resp.text.contains("main.rs"), "Should show main.rs in src/");
129 }
130
131 // ── File view ──
132
133 #[tokio::test]
134 async fn git_file_view() {
135 let tmp = tempfile::TempDir::new().unwrap();
136 make_test_repo(tmp.path());
137 let mut h = setup_git_harness(&tmp).await;
138
139 let resp = h
140 .client
141 .get("/git/testowner/testrepo/tree/main/src/main.rs")
142 .await;
143 assert_eq!(
144 resp.status, 200,
145 "File view failed: {} {}",
146 resp.status, resp.text
147 );
148 assert!(
149 resp.text.contains("println!"),
150 "Should show file content with println!"
151 );
152 }
153
154 #[tokio::test]
155 async fn git_file_nonexistent_returns_404() {
156 let tmp = tempfile::TempDir::new().unwrap();
157 make_test_repo(tmp.path());
158 let mut h = setup_git_harness(&tmp).await;
159
160 let resp = h
161 .client
162 .get("/git/testowner/testrepo/tree/main/nope.txt")
163 .await;
164 assert_eq!(resp.status, 404);
165 }
166
167 // ── Commit log ──
168
169 #[tokio::test]
170 async fn git_commit_log() {
171 let tmp = tempfile::TempDir::new().unwrap();
172 make_test_repo(tmp.path());
173 let mut h = setup_git_harness(&tmp).await;
174
175 let resp = h.client.get("/git/testowner/testrepo/commits/main").await;
176 assert_eq!(
177 resp.status, 200,
178 "Commit log failed: {} {}",
179 resp.status, resp.text
180 );
181 assert!(
182 resp.text.contains("Initial commit"),
183 "Should show commit message"
184 );
185 assert!(
186 resp.text.contains("Add world output"),
187 "Should show second commit message"
188 );
189 }
190
191 // ── Raw file ──
192
193 #[tokio::test]
194 async fn git_raw_file() {
195 let tmp = tempfile::TempDir::new().unwrap();
196 make_test_repo(tmp.path());
197 let mut h = setup_git_harness(&tmp).await;
198
199 let resp = h
200 .client
201 .get("/git/testowner/testrepo/raw/main/README.md")
202 .await;
203 assert_eq!(
204 resp.status, 200,
205 "Raw file failed: {} {}",
206 resp.status, resp.text
207 );
208 assert!(
209 resp.text.contains("# Test Repo"),
210 "Should return raw file content"
211 );
212 }
213
214 // ── Path traversal ──
215
216 #[tokio::test]
217 async fn git_path_traversal_rejected() {
218 let tmp = tempfile::TempDir::new().unwrap();
219 make_test_repo(tmp.path());
220 let mut h = setup_git_harness(&tmp).await;
221
222 let resp = h.client.get("/git/../etc/testrepo").await;
223 // Axum may normalize or reject; we just check it doesn't succeed
224 assert_eq!(
225 resp.status, 404,
226 "Traversal should not succeed: {}",
227 resp.status
228 );
229 }
230
231 // ── Invalid ref ──
232
233 #[tokio::test]
234 async fn git_invalid_ref_returns_404() {
235 let tmp = tempfile::TempDir::new().unwrap();
236 make_test_repo(tmp.path());
237 let mut h = setup_git_harness(&tmp).await;
238
239 let resp = h
240 .client
241 .get("/git/testowner/testrepo/tree/nonexistent-branch")
242 .await;
243 assert_eq!(resp.status, 404);
244 }
245
246 // ── Visibility: private repo ──
247
248 #[tokio::test]
249 async fn git_private_repo_hidden_from_anonymous() {
250 let tmp = tempfile::TempDir::new().unwrap();
251 make_test_repo(tmp.path());
252 let mut h = setup_git_harness(&tmp).await;
253
254 // Visit the repo to auto-register it
255 let resp = h.client.get("/git/testowner/testrepo").await;
256 assert_eq!(resp.status, 200, "{}", resp.text);
257
258 // Set visibility to private via SQL
259 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
260 .execute(&h.db)
261 .await
262 .unwrap();
263
264 // Log out so we're anonymous
265 h.client.post_form("/logout", "").await;
266
267 let resp = h.client.get("/git/testowner/testrepo").await;
268 assert_eq!(
269 resp.status, 404,
270 "Private repo should be 404 for anonymous users"
271 );
272 }
273
274 #[tokio::test]
275 async fn git_private_repo_visible_to_owner() {
276 let tmp = tempfile::TempDir::new().unwrap();
277 make_test_repo(tmp.path());
278 let mut h = setup_git_harness(&tmp).await;
279
280 // Visit the repo to auto-register it
281 let resp = h.client.get("/git/testowner/testrepo").await;
282 assert_eq!(resp.status, 200, "{}", resp.text);
283
284 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
285 .execute(&h.db)
286 .await
287 .unwrap();
288
289 // Log in as the owner
290 h.login("testowner", "password123").await;
291
292 let resp = h.client.get("/git/testowner/testrepo").await;
293 assert_eq!(
294 resp.status, 200,
295 "Owner should see private repo: {} {}",
296 resp.status, resp.text
297 );
298 }
299
300 #[tokio::test]
301 async fn git_private_repo_visible_to_read_collaborator() {
302 // Run #21 authz reconciliation: a read-collaborator (who can already clone
303 // over SSH) must be able to read a private repo over HTTP too, previously
304 // HTTP was owner-only and 404'd them.
305 let tmp = tempfile::TempDir::new().unwrap();
306 make_test_repo(tmp.path());
307 let mut h = setup_git_harness(&tmp).await;
308
309 let resp = h.client.get("/git/testowner/testrepo").await; // auto-register
310 assert_eq!(resp.status, 200, "{}", resp.text);
311 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
312 .execute(&h.db)
313 .await
314 .unwrap();
315
316 // A logged-in non-collaborator is still denied.
317 let outsider = h
318 .signup("outsider", "outsider@example.com", "password123")
319 .await;
320 h.login("outsider", "password123").await;
321 let resp = h.client.get("/git/testowner/testrepo").await;
322 assert_eq!(
323 resp.status, 404,
324 "non-collaborator must not see a private repo"
325 );
326
327 // Grant read access, then they can see it.
328 let repo_id: uuid::Uuid =
329 sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'")
330 .fetch_one(&h.db)
331 .await
332 .unwrap();
333 sqlx::query(
334 "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)",
335 )
336 .bind(repo_id)
337 .bind(outsider)
338 .execute(&h.db)
339 .await
340 .unwrap();
341
342 let resp = h.client.get("/git/testowner/testrepo").await;
343 assert_eq!(
344 resp.status, 200,
345 "read-collaborator should see private repo over HTTP: {} {}",
346 resp.status, resp.text
347 );
348 }
349
350 // ── Commit detail ──
351
352 #[tokio::test]
353 async fn git_commit_detail_page() {
354 let tmp = tempfile::TempDir::new().unwrap();
355 make_test_repo(tmp.path());
356 let mut h = setup_git_harness(&tmp).await;
357
358 // Get the HEAD commit OID from the commit log page
359 let log_resp = h.client.get("/git/testowner/testrepo/commits/main").await;
360 assert_eq!(log_resp.status, 200, "{}", log_resp.text);
361
362 // Extract a commit OID from the page (look for /commit/ link)
363 let oid = log_resp
364 .text
365 .split("/git/testowner/testrepo/commit/")
366 .nth(1)
367 .and_then(|s| s.split('"').next())
368 .expect("Should find commit OID link in commit log");
369
370 let resp = h
371 .client
372 .get(&format!("/git/testowner/testrepo/commit/{oid}"))
373 .await;
374 assert_eq!(
375 resp.status, 200,
376 "Commit detail failed: {} {}",
377 resp.status, resp.text
378 );
379 assert!(resp.text.contains("file"), "Should show diff stats");
380 }
381
382 #[tokio::test]
383 async fn git_commit_detail_root_commit() {
384 let tmp = tempfile::TempDir::new().unwrap();
385 make_test_repo(tmp.path());
386 let mut h = setup_git_harness(&tmp).await;
387
388 // Get the root commit, it's the oldest one. Fetch commit log page 1.
389 let log_resp = h.client.get("/git/testowner/testrepo/commits/main").await;
390 assert_eq!(log_resp.status, 200, "{}", log_resp.text);
391
392 // The root commit's message is "Initial commit"
393 // Find its OID link
394 let text = &log_resp.text;
395 let initial_idx = text
396 .find("Initial commit")
397 .expect("Should find Initial commit");
398 // The OID link is nearby, search after the message for /commit/ link
399 let after_initial = &text[initial_idx..];
400 let oid = after_initial
401 .split("/git/testowner/testrepo/commit/")
402 .nth(1)
403 .and_then(|s| s.split('"').next())
404 .expect("Should find commit OID for root commit");
405
406 let resp = h
407 .client
408 .get(&format!("/git/testowner/testrepo/commit/{oid}"))
409 .await;
410 assert_eq!(
411 resp.status, 200,
412 "Root commit detail failed: {} {}",
413 resp.status, resp.text
414 );
415 assert!(
416 resp.text.contains("Initial commit"),
417 "Should show root commit message"
418 );
419 // Root commit should have additions (all files are new)
420 assert!(
421 resp.text.contains("insertion"),
422 "Root commit should show insertions"
423 );
424 }
425
426 #[tokio::test]
427 async fn git_commit_detail_nonexistent_404() {
428 let tmp = tempfile::TempDir::new().unwrap();
429 make_test_repo(tmp.path());
430 let mut h = setup_git_harness(&tmp).await;
431
432 let resp = h
433 .client
434 .get("/git/testowner/testrepo/commit/0000000000000000000000000000000000000000")
435 .await;
436 assert_eq!(resp.status, 404);
437 }
438
439 #[tokio::test]
440 async fn git_commit_detail_invalid_oid_404() {
441 let tmp = tempfile::TempDir::new().unwrap();
442 make_test_repo(tmp.path());
443 let mut h = setup_git_harness(&tmp).await;
444
445 let resp = h
446 .client
447 .get("/git/testowner/testrepo/commit/not-a-valid-oid")
448 .await;
449 assert_eq!(resp.status, 404);
450 }
451
452 // ── Blame view ──
453
454 #[tokio::test]
455 async fn git_blame_view() {
456 let tmp = tempfile::TempDir::new().unwrap();
457 make_test_repo(tmp.path());
458 let mut h = setup_git_harness(&tmp).await;
459
460 let resp = h
461 .client
462 .get("/git/testowner/testrepo/blame/main/src/main.rs")
463 .await;
464 assert_eq!(
465 resp.status, 200,
466 "Blame view failed: {} {}",
467 resp.status, resp.text
468 );
469 assert!(
470 resp.text.contains("println!"),
471 "Blame should show file content"
472 );
473 // Should have commit short OIDs in the blame gutter
474 assert!(
475 resp.text.contains("/commit/"),
476 "Blame should link to commits"
477 );
478 }
479
480 #[tokio::test]
481 async fn git_blame_nonexistent_file_404() {
482 let tmp = tempfile::TempDir::new().unwrap();
483 make_test_repo(tmp.path());
484 let mut h = setup_git_harness(&tmp).await;
485
486 let resp = h
487 .client
488 .get("/git/testowner/testrepo/blame/main/nope.txt")
489 .await;
490 assert_eq!(resp.status, 404);
491 }
492
493 // ── User repos listing ──
494
495 #[tokio::test]
496 async fn git_user_repos_listing() {
497 let tmp = tempfile::TempDir::new().unwrap();
498 make_test_repo(tmp.path());
499 let mut h = setup_git_harness(&tmp).await;
500
501 // Visit repo to auto-register it
502 let resp = h.client.get("/git/testowner/testrepo").await;
503 assert_eq!(resp.status, 200, "{}", resp.text);
504
505 let resp = h.client.get("/git/testowner").await;
506 assert_eq!(
507 resp.status, 200,
508 "User repos listing failed: {} {}",
509 resp.status, resp.text
510 );
511 assert!(resp.text.contains("testrepo"), "Should list the repo");
512 }
513
514 #[tokio::test]
515 async fn git_user_repos_nonexistent_user_404() {
516 let tmp = tempfile::TempDir::new().unwrap();
517 make_test_repo(tmp.path());
518 let mut h = setup_git_harness(&tmp).await;
519
520 let resp = h.client.get("/git/nobody").await;
521 assert_eq!(resp.status, 404);
522 }
523
524 // ── Git explore (landing) ──
525
526 #[tokio::test]
527 async fn git_landing_shows_explore_logged_in() {
528 let tmp = tempfile::TempDir::new().unwrap();
529 make_test_repo(tmp.path());
530 let mut h = setup_git_harness(&tmp).await;
531 h.login("testowner", "password123").await;
532
533 // Visit repo to auto-register it
534 let resp = h.client.get("/git/testowner/testrepo").await;
535 assert_eq!(resp.status, 200, "{}", resp.text);
536
537 let resp = h.client.get("/git").await;
538 assert_eq!(
539 resp.status, 200,
540 "Explore page should return 200 for logged-in users"
541 );
542 assert!(
543 resp.text.contains("Repositories"),
544 "Should show Repositories heading"
545 );
546 }
547
548 #[tokio::test]
549 async fn git_landing_shows_explore_anonymous() {
550 let tmp = tempfile::TempDir::new().unwrap();
551 make_test_repo(tmp.path());
552 let mut h = setup_git_harness(&tmp).await;
553
554 // Visit repo to auto-register it
555 let resp = h.client.get("/git/testowner/testrepo").await;
556 assert_eq!(resp.status, 200, "{}", resp.text);
557
558 h.client.post_form("/logout", "").await;
559
560 let resp = h.client.get("/git").await;
561 assert_eq!(
562 resp.status, 200,
563 "Explore page should return 200 for anonymous users"
564 );
565 assert!(
566 resp.text.contains("Repositories"),
567 "Should show Repositories heading"
568 );
569 }
570
571 #[tokio::test]
572 async fn git_explore_page_shows_public_repos() {
573 let tmp = tempfile::TempDir::new().unwrap();
574 make_test_repo(tmp.path());
575 let mut h = setup_git_harness(&tmp).await;
576
577 // Visit repo to auto-register it. Auto-registration lands private since
578 // migration 182, so publish it explicitly: what this test is about is the
579 // explore page listing a public repo, not what the default happens to be.
580 // Its sibling below covers the private case.
581 let resp = h.client.get("/git/testowner/testrepo").await;
582 assert_eq!(resp.status, 200, "{}", resp.text);
583
584 sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'")
585 .execute(&h.db)
586 .await
587 .unwrap();
588
589 let resp = h.client.get("/git").await;
590 assert_eq!(resp.status, 200);
591 assert!(resp.text.contains("testowner"), "Should show owner name");
592 assert!(resp.text.contains("testrepo"), "Should show repo name");
593 }
594
595 #[tokio::test]
596 async fn git_explore_page_hides_private_repos() {
597 let tmp = tempfile::TempDir::new().unwrap();
598 make_test_repo(tmp.path());
599 let mut h = setup_git_harness(&tmp).await;
600
601 // Visit repo to auto-register it
602 let resp = h.client.get("/git/testowner/testrepo").await;
603 assert_eq!(resp.status, 200, "{}", resp.text);
604
605 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
606 .execute(&h.db)
607 .await
608 .unwrap();
609
610 // Logout and check explore page
611 h.client.post_form("/logout", "").await;
612
613 let resp = h.client.get("/git").await;
614 assert_eq!(resp.status, 200);
615 assert!(
616 !resp.text.contains("testrepo"),
617 "Private repo should not appear on explore page"
618 );
619 }
620
621 // ── File history ──
622
623 #[tokio::test]
624 async fn git_file_history() {
625 let tmp = tempfile::TempDir::new().unwrap();
626 make_test_repo(tmp.path());
627 let mut h = setup_git_harness(&tmp).await;
628
629 let resp = h
630 .client
631 .get("/git/testowner/testrepo/log/main/src/main.rs")
632 .await;
633 assert_eq!(
634 resp.status, 200,
635 "File history failed: {} {}",
636 resp.status, resp.text
637 );
638 // Both commits touched src/main.rs
639 assert!(
640 resp.text.contains("Initial commit"),
641 "Should show initial commit"
642 );
643 assert!(
644 resp.text.contains("Add world output"),
645 "Should show second commit"
646 );
647 }
648
649 #[tokio::test]
650 async fn git_file_history_filters_unrelated() {
651 let tmp = tempfile::TempDir::new().unwrap();
652 make_test_repo(tmp.path());
653 let mut h = setup_git_harness(&tmp).await;
654
655 let resp = h
656 .client
657 .get("/git/testowner/testrepo/log/main/README.md")
658 .await;
659 assert_eq!(
660 resp.status, 200,
661 "File history failed: {} {}",
662 resp.status, resp.text
663 );
664 // README.md was only added in the initial commit, not changed in the second
665 assert!(
666 resp.text.contains("Initial commit"),
667 "Should show initial commit for README.md"
668 );
669 assert!(
670 !resp.text.contains("Add world output"),
671 "Should not show unrelated commit"
672 );
673 }
674
675 #[tokio::test]
676 async fn git_file_history_nonexistent_file() {
677 let tmp = tempfile::TempDir::new().unwrap();
678 make_test_repo(tmp.path());
679 let mut h = setup_git_harness(&tmp).await;
680
681 let resp = h
682 .client
683 .get("/git/testowner/testrepo/log/main/nope.txt")
684 .await;
685 assert_eq!(
686 resp.status, 200,
687 "Nonexistent file history should render empty, not 404: {} {}",
688 resp.status, resp.text
689 );
690 assert!(
691 resp.text.contains("No commits found"),
692 "Should show empty message"
693 );
694 }
695
696 #[tokio::test]
697 async fn git_file_view_has_history_link() {
698 let tmp = tempfile::TempDir::new().unwrap();
699 make_test_repo(tmp.path());
700 let mut h = setup_git_harness(&tmp).await;
701
702 let resp = h
703 .client
704 .get("/git/testowner/testrepo/tree/main/src/main.rs")
705 .await;
706 assert_eq!(resp.status, 200, "{}", resp.text);
707 assert!(
708 resp.text.contains("/log/main/src/main.rs"),
709 "Should have history link"
710 );
711 }
712
713 // ── File view line linking ──
714
715 #[tokio::test]
716 async fn git_file_view_has_line_links() {
717 let tmp = tempfile::TempDir::new().unwrap();
718 make_test_repo(tmp.path());
719 let mut h = setup_git_harness(&tmp).await;
720
721 let resp = h
722 .client
723 .get("/git/testowner/testrepo/tree/main/src/main.rs")
724 .await;
725 assert_eq!(resp.status, 200, "{}", resp.text);
726 assert!(
727 resp.text.contains("href=\"#L1\""),
728 "Should have line link anchors"
729 );
730 assert!(
731 resp.text.contains("id=\"L1\""),
732 "Should have line anchor IDs"
733 );
734 }
735
736 // ── File view has blame link ──
737
738 #[tokio::test]
739 async fn git_file_view_has_blame_link() {
740 let tmp = tempfile::TempDir::new().unwrap();
741 make_test_repo(tmp.path());
742 let mut h = setup_git_harness(&tmp).await;
743
744 let resp = h
745 .client
746 .get("/git/testowner/testrepo/tree/main/src/main.rs")
747 .await;
748 assert_eq!(resp.status, 200, "{}", resp.text);
749 assert!(
750 resp.text.contains("/blame/main/src/main.rs"),
751 "Should have blame link"
752 );
753 }
754
755 // ── Nav bar consistency ──
756
757 #[tokio::test]
758 async fn git_nav_bar_present_on_all_pages() {
759 let tmp = tempfile::TempDir::new().unwrap();
760 make_test_repo(tmp.path());
761 let mut h = setup_git_harness(&tmp).await;
762
763 // Repo overview
764 let resp = h.client.get("/git/testowner/testrepo").await;
765 assert!(
766 resp.text.contains("git-nav-links"),
767 "Repo overview should have nav"
768 );
769
770 let resp = h.client.get("/git/testowner/testrepo/tree/main").await;
771 assert!(resp.text.contains("git-nav-links"), "Tree should have nav");
772
773 // Subdirectory
774 let resp = h.client.get("/git/testowner/testrepo/tree/main/src").await;
775 assert!(
776 resp.text.contains("git-nav-links"),
777 "Subdirectory should have nav"
778 );
779
780 // File view
781 let resp = h
782 .client
783 .get("/git/testowner/testrepo/tree/main/src/main.rs")
784 .await;
785 assert!(
786 resp.text.contains("git-nav-links"),
787 "File view should have nav"
788 );
789
790 let resp = h.client.get("/git/testowner/testrepo/commits/main").await;
791 assert!(
792 resp.text.contains("git-nav-links"),
793 "Commits should have nav"
794 );
795 }
796
797 // ── No emoji in tree views ──
798
799 #[tokio::test]
800 async fn git_tree_no_emoji() {
801 let tmp = tempfile::TempDir::new().unwrap();
802 make_test_repo(tmp.path());
803 let mut h = setup_git_harness(&tmp).await;
804
805 let resp = h.client.get("/git/testowner/testrepo").await;
806 assert!(
807 !resp.text.contains("\u{1F4C1}"),
808 "Repo overview should not have folder emoji"
809 );
810 assert!(
811 !resp.text.contains("\u{1F4C4}"),
812 "Repo overview should not have file emoji"
813 );
814 assert!(
815 !resp.text.contains("📁"),
816 "No folder emoji HTML entity"
817 );
818 assert!(
819 !resp.text.contains("📄"),
820 "No file emoji HTML entity"
821 );
822
823 let resp = h.client.get("/git/testowner/testrepo/tree/main/src").await;
824 assert!(
825 !resp.text.contains("\u{1F4C1}"),
826 "Subdirectory should not have folder emoji"
827 );
828 assert!(
829 !resp.text.contains("\u{1F4C4}"),
830 "Subdirectory should not have file emoji"
831 );
832 }
833
834 // ── Personal access tokens (git over HTTPS) ──
835
836 fn basic_auth(token: &str) -> String {
837 use base64::Engine;
838 // git puts the token in the password field; username is ignored.
839 let creds = base64::engine::general_purpose::STANDARD.encode(format!("x:{token}"));
840 format!("Basic {creds}")
841 }
842
843 #[tokio::test]
844 async fn git_token_clones_private_repo_and_revokes() {
845 let tmp = tempfile::TempDir::new().unwrap();
846 make_test_repo(tmp.path());
847 let mut h = setup_git_harness(&tmp).await; // signs up + logs in testowner
848
849 h.client.get("/git/testowner/testrepo").await; // auto-register
850 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
851 .execute(&h.db)
852 .await
853 .unwrap();
854
855 // Owner mints a read-only token; the create response body is the plaintext.
856 h.login("testowner", "password123").await;
857 h.client.fetch_csrf_token().await;
858 let resp = h
859 .client
860 .post_form("/api/users/me/git-tokens", "name=laptop")
861 .await;
862 assert_eq!(
863 resp.status, 200,
864 "create token: {} {}",
865 resp.status, resp.text
866 );
867 let token = resp.text.trim().to_string();
868 assert!(
869 token.starts_with("mnw_"),
870 "expected mnw_-prefixed token, got: {token}"
871 );
872
873 // Simulate a CLI clone: no session cookie, token via Basic auth.
874 h.client.clear_cookies();
875 let info_refs = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack";
876
877 // No credentials → private repo is invisible.
878 let resp = h.client.get(info_refs).await;
879 assert_eq!(resp.status, 404, "anonymous must not reach a private repo");
880
881 // Valid token → clone advertisement succeeds.
882 let resp = h
883 .client
884 .request_with_headers(
885 "GET",
886 info_refs,
887 None,
888 &[("Authorization", &basic_auth(&token))],
889 )
890 .await;
891 assert_eq!(
892 resp.status, 200,
893 "token clone failed: {} {}",
894 resp.status, resp.text
895 );
896 assert!(
897 resp.text.contains("refs/heads/main"),
898 "advertisement missing refs: {}",
899 resp.text
900 );
901
902 // Garbage token → still 404.
903 let resp = h
904 .client
905 .request_with_headers(
906 "GET",
907 info_refs,
908 None,
909 &[("Authorization", &basic_auth("mnw_bogus"))],
910 )
911 .await;
912 assert_eq!(resp.status, 404, "bad token must not authorize");
913
914 // Read-only token cannot push: receive-pack advertisement is forbidden.
915 let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
916 let resp = h
917 .client
918 .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
919 .await;
920 assert_eq!(
921 resp.status, 403,
922 "read-only token must not get a push advertisement: {}",
923 resp.status
924 );
925
926 // Revoke the token (re-auth as owner first).
927 let token_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_access_tokens LIMIT 1")
928 .fetch_one(&h.db)
929 .await
930 .unwrap();
931 h.login("testowner", "password123").await;
932 h.client.fetch_csrf_token().await;
933 let resp = h
934 .client
935 .delete(&format!("/api/users/me/git-tokens/{token_id}"))
936 .await;
937 assert_eq!(resp.status, 204, "revoke: {} {}", resp.status, resp.text);
938
939 // Revoked token no longer authorizes.
940 h.client.clear_cookies();
941 let resp = h
942 .client
943 .request_with_headers(
944 "GET",
945 info_refs,
946 None,
947 &[("Authorization", &basic_auth(&token))],
948 )
949 .await;
950 assert_eq!(resp.status, 404, "revoked token must stop working");
951 }
952
953 #[tokio::test]
954 async fn git_push_token_gets_receive_pack_advertisement() {
955 let tmp = tempfile::TempDir::new().unwrap();
956 make_test_repo(tmp.path());
957 let mut h = setup_git_harness(&tmp).await;
958
959 h.client.get("/git/testowner/testrepo").await;
960 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
961 .execute(&h.db)
962 .await
963 .unwrap();
964
965 h.login("testowner", "password123").await;
966 h.client.fetch_csrf_token().await;
967 let resp = h
968 .client
969 .post_form("/api/users/me/git-tokens", "name=ci&can_push=on")
970 .await;
971 assert_eq!(
972 resp.status, 200,
973 "create push token: {} {}",
974 resp.status, resp.text
975 );
976 let token = resp.text.trim().to_string();
977
978 h.client.clear_cookies();
979 let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
980 let resp = h
981 .client
982 .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
983 .await;
984 assert_eq!(
985 resp.status, 200,
986 "push token should get receive-pack advertisement: {} {}",
987 resp.status, resp.text
988 );
989 assert!(
990 resp.text.contains("# service=git-receive-pack"),
991 "missing receive-pack banner: {}",
992 resp.text
993 );
994 }
995
996 // Suspension has to mean the same thing over HTTPS that it means over SSH.
997 // `git_ssh::dispatch` loads the user and refuses a suspended or deactivated
998 // account; the HTTPS funnel used to authenticate a token on its hash and expiry
999 // alone, so a token minted before a suspension kept working. Both credential
1000 // branches are covered here: the PAT and the session cookie.
1001 #[tokio::test]
1002 async fn suspended_account_loses_git_over_https() {
1003 let tmp = tempfile::TempDir::new().unwrap();
1004 make_test_repo(tmp.path());
1005 let mut h = setup_git_harness(&tmp).await;
1006
1007 h.client.get("/git/testowner/testrepo").await; // auto-register
1008 sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
1009 .execute(&h.db)
1010 .await
1011 .unwrap();
1012
1013 h.login("testowner", "password123").await;
1014 h.client.fetch_csrf_token().await;
1015 let resp = h
1016 .client
1017 .post_form("/api/users/me/git-tokens", "name=ci&can_push=on")
1018 .await;
1019 assert_eq!(resp.status, 200, "create push token: {}", resp.text);
1020 let token = resp.text.trim().to_string();
1021
1022 let upload = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack";
1023 let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
1024
1025 // Baseline: the token reads and pushes, the cookie reads. The token half
1026 // runs with the cookies cleared, since the funnel prefers a session and a
1027 // session is not a push credential.
1028 h.client.clear_cookies();
1029 let resp = h
1030 .client
1031 .request_with_headers(
1032 "GET",
1033 upload,
1034 None,
1035 &[("Authorization", &basic_auth(&token))],
1036 )
1037 .await;
1038 assert_eq!(
1039 resp.status, 200,
1040 "token read before suspension: {}",
1041 resp.text
1042 );
1043 let resp = h
1044 .client
1045 .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
1046 .await;
1047 assert_eq!(
1048 resp.status, 200,
1049 "token push advert before suspension: {}",
1050 resp.text
1051 );
1052 h.login("testowner", "password123").await;
1053 let resp = h.client.get(upload).await;
1054 assert_eq!(
1055 resp.status, 200,
1056 "cookie read before suspension: {}",
1057 resp.text
1058 );
1059
1060 sqlx::query("UPDATE users SET suspended_at = now() WHERE username = 'testowner'")
1061 .execute(&h.db)
1062 .await
1063 .unwrap();
1064
1065 // The cookie branch: the session survives, the account does not.
1066 let resp = h.client.get(upload).await;
1067 assert_eq!(
1068 resp.status, 404,
1069 "a suspended account must not read over a session cookie: {}",
1070 resp.text
1071 );
1072
1073 // The token branch, read and push both.
1074 h.client.clear_cookies();
1075 let resp = h
1076 .client
1077 .request_with_headers(
1078 "GET",
1079 upload,
1080 None,
1081 &[("Authorization", &basic_auth(&token))],
1082 )
1083 .await;
1084 assert_eq!(
1085 resp.status, 404,
1086 "a suspended account must not clone over HTTPS: {}",
1087 resp.text
1088 );
1089 let resp = h
1090 .client
1091 .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
1092 .await;
1093 assert_eq!(
1094 resp.status, 404,
1095 "a suspended account must not push over HTTPS: {}",
1096 resp.text
1097 );
1098
1099 // Deactivation is refused on the same terms, the way SSH pairs them.
1100 sqlx::query(
1101 "UPDATE users SET suspended_at = NULL, deactivated_at = now() WHERE username = 'testowner'",
1102 )
1103 .execute(&h.db)
1104 .await
1105 .unwrap();
1106 let resp = h
1107 .client
1108 .request_with_headers(
1109 "GET",
1110 upload,
1111 None,
1112 &[("Authorization", &basic_auth(&token))],
1113 )
1114 .await;
1115 assert_eq!(
1116 resp.status, 404,
1117 "a deactivated account must not clone over HTTPS: {}",
1118 resp.text
1119 );
1120
1121 // Lifting the suspension restores the same token, so it is account standing
1122 // being enforced and not the token being invalidated.
1123 sqlx::query("UPDATE users SET deactivated_at = NULL WHERE username = 'testowner'")
1124 .execute(&h.db)
1125 .await
1126 .unwrap();
1127 let resp = h
1128 .client
1129 .request_with_headers(
1130 "GET",
1131 upload,
1132 None,
1133 &[("Authorization", &basic_auth(&token))],
1134 )
1135 .await;
1136 assert_eq!(
1137 resp.status, 200,
1138 "token should work again once the account is in good standing: {}",
1139 resp.text
1140 );
1141 }
1142
1143 // UX-S1: git push (receive-pack) must reject session-cookie auth, even for the
1144 // repo OWNER. These routes are merged outside the CsrfRouter/origin_gate tree, so
1145 // a cookie-authed push would be drivable cross-origin with only git wire-format
1146 // friction. Push now requires a push-scoped PAT; the owner's browser session does
1147 // not authorize a write.
1148 #[tokio::test]
1149 async fn git_session_cookie_cannot_push() {
1150 let tmp = tempfile::TempDir::new().unwrap();
1151 make_test_repo(tmp.path());
1152 let mut h = setup_git_harness(&tmp).await; // signs up + logs in testowner
1153
1154 h.client.get("/git/testowner/testrepo").await; // auto-register
1155
1156 // Owner is logged in (session cookie tracked by the client) and CAN read.
1157 h.login("testowner", "password123").await;
1158
1159 // Read advertisement (upload-pack) works with the cookie, owner has read.
1160 let upload = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack";
1161 let resp = h.client.get(upload).await;
1162 assert_eq!(
1163 resp.status, 200,
1164 "owner cookie should read: {} {}",
1165 resp.status, resp.text
1166 );
1167
1168 // Push advertisement (receive-pack) must be REFUSED for cookie auth, no PAT.
1169 let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
1170 let resp = h.client.get(recv).await;
1171 assert_eq!(
1172 resp.status, 403,
1173 "a session cookie must not authorize git push, even for the owner (UX-S1): {} {}",
1174 resp.status, resp.text
1175 );
1176
1177 // The receive-pack POST (the actual push) is likewise refused for cookie auth.
1178 let recv_post = "/git/testowner/testrepo.git/git-receive-pack";
1179 let resp = h
1180 .client
1181 .request_with_headers(
1182 "POST",
1183 recv_post,
1184 Some("0000"),
1185 &[("Content-Type", "application/x-git-receive-pack-request")],
1186 )
1187 .await;
1188 assert_eq!(
1189 resp.status, 403,
1190 "cookie-authed receive-pack POST must be refused (UX-S1): {} {}",
1191 resp.status, resp.text
1192 );
1193 }
1194
1195 // ── Smart HTTP (git clone) ──
1196
1197 /// Tip commit sha of refs/heads/main from the on-disk bare repo.
1198 fn main_tip_sha(dir: &std::path::Path) -> String {
1199 crate::harness::gitfixture::main_tip_sha(dir, "testowner", "testrepo")
1200 }
1201
1202 #[tokio::test]
1203 async fn git_smart_http_info_refs_advertises_refs() {
1204 let tmp = tempfile::TempDir::new().unwrap();
1205 make_test_repo(tmp.path());
1206 let mut h = setup_git_harness(&tmp).await;
1207
1208 let resp = h
1209 .client
1210 .get("/git/testowner/testrepo.git/info/refs?service=git-upload-pack")
1211 .await;
1212 assert_eq!(
1213 resp.status, 200,
1214 "info/refs failed: {} {}",
1215 resp.status, resp.text
1216 );
1217 let ct = resp
1218 .headers
1219 .get("content-type")
1220 .and_then(|v| v.to_str().ok())
1221 .unwrap_or("");
1222 assert_eq!(
1223 ct, "application/x-git-upload-pack-advertisement",
1224 "wrong content-type: {ct}"
1225 );
1226 assert!(
1227 resp.text.contains("# service=git-upload-pack"),
1228 "missing service banner: {}",
1229 resp.text
1230 );
1231 assert!(
1232 resp.text.contains("refs/heads/main"),
1233 "advertisement missing main ref: {}",
1234 resp.text
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn git_smart_http_upload_pack_streams_packfile() {
1240 // Exercises the streamed (Body::from_stream) upload-pack response added in
1241 // Run #20 (Performance HIGH): a real clone negotiation must still produce a
1242 // valid packfile, proving the stream + concurrency-permit path didn't
1243 // corrupt the protocol framing.
1244 let tmp = tempfile::TempDir::new().unwrap();
1245 make_test_repo(tmp.path());
1246 let sha = main_tip_sha(tmp.path());
1247 let mut h = setup_git_harness(&tmp).await;
1248
1249 // Minimal upload-pack request: one want line (capabilities ride the first
1250 // line; no side-band so the pack returns raw), flush, then done.
1251 let want = format!("want {sha} ofs-delta agent=git/test\n");
1252 let body = format!("{:04x}{want}00000009done\n", want.len() + 4);
1253
1254 let resp = h
1255 .client
1256 .request_with_headers(
1257 "POST",
1258 "/git/testowner/testrepo.git/git-upload-pack",
1259 Some(&body),
1260 &[("Content-Type", "application/x-git-upload-pack-request")],
1261 )
1262 .await;
1263
1264 assert_eq!(
1265 resp.status, 200,
1266 "upload-pack failed: {} {}",
1267 resp.status, resp.text
1268 );
1269 let ct = resp
1270 .headers
1271 .get("content-type")
1272 .and_then(|v| v.to_str().ok())
1273 .unwrap_or("");
1274 assert_eq!(
1275 ct, "application/x-git-upload-pack-result",
1276 "wrong content-type: {ct}"
1277 );
1278 // The streamed body must carry the packfile magic, ASCII "PACK" survives
1279 // the lossy-UTF8 view the test client exposes.
1280 assert!(
1281 resp.text.contains("PACK"),
1282 "streamed response carried no packfile ({} bytes)",
1283 resp.text.len()
1284 );
1285 }
1286