Skip to main content

max / makenotwork

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