repository_browser_test.go
7171 bytes
1package web
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "github.com/charmbracelet/soft-serve/pkg/access"
10 "github.com/charmbracelet/soft-serve/pkg/config"
11 "github.com/charmbracelet/soft-serve/pkg/proto"
12)
13
14func TestRepositoryBrowserRawSizeLimit(t *testing.T) {
15 ctx, be, _ := newLFSTestContext(t)
16 cfg := config.FromContext(ctx)
17 cfg.HTTP.WebUI.Enabled = true
18 allowKeyless := true
19 cfg.AllowKeyless = &allowKeyless
20 anonymous := access.ReadOnlyAccess
21 cfg.AnonAccess = &anonymous
22 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
23 if err != nil {
24 t.Fatal(err)
25 }
26 if _, err := be.CreateRepository(ctx, "large", owner, proto.RepositoryOptions{}); err != nil {
27 t.Fatal(err)
28 }
29 commitHomepageFile(t, cfg.DataPath, "large", "large.bin", strings.Repeat("x", (16<<20)+1))
30
31 w := httptest.NewRecorder()
32 NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/large/@/raw?path=large.bin", nil))
33 if w.Code != http.StatusRequestEntityTooLarge {
34 t.Fatalf("oversized raw response = %d: %s", w.Code, w.Body.String())
35 }
36}
37
38func TestRepositoryBrowserOverviewAndTreeRoutes(t *testing.T) {
39 ctx, be, _ := newLFSTestContext(t)
40 cfg := config.FromContext(ctx)
41 cfg.HTTP.WebUI.Enabled = true
42 cfg.SSH.PublicURL = "ssh://git@ssh.example"
43 cfg.HTTP.PublicURL = "https://git.example"
44 allowKeyless := true
45 cfg.AllowKeyless = &allowKeyless
46 anonymous := access.ReadOnlyAccess
47 cfg.AnonAccess = &anonymous
48
49 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
50 if err != nil {
51 t.Fatal(err)
52 }
53 if _, err := be.CreateRepository(ctx, "team/nested", owner, proto.RepositoryOptions{
54 ProjectName: "Nested project", Description: "A public repository",
55 }); err != nil {
56 t.Fatal(err)
57 }
58 if _, err := be.CreateRepository(ctx, "team/empty", owner, proto.RepositoryOptions{}); err != nil {
59 t.Fatal(err)
60 }
61 if _, err := be.CreateRepository(ctx, "team/private", owner, proto.RepositoryOptions{Private: true}); err != nil {
62 t.Fatal(err)
63 }
64 commitHomepageFile(t, cfg.DataPath, "team/nested", "README.md", "# Hello\n\n\n")
65 commitHomepageFile(t, cfg.DataPath, "team/nested", "dir/a.txt", "a")
66 commitHomepageFile(t, cfg.DataPath, "team/nested", "dir/nested/b.txt", "b")
67 commitHomepageFile(t, cfg.DataPath, "team/nested", "source.go", "<script>alert(1)</script>")
68 commitHomepageFile(t, cfg.DataPath, "team/nested", "docs/guide.md", "# Guide\n\n[Source](../source.go)\n\n\n\n<script>alert(1)</script>\n")
69 commitHomepageFile(t, cfg.DataPath, "team/nested", "images/icon.png", string([]byte{'\x89', 'P', 'N', 'G'}))
70 commitHomepageFile(t, cfg.DataPath, "team/nested", "unsafe.svg", "<svg></svg>")
71 commitHomepageFile(t, cfg.DataPath, "team/nested", "unsafe.html", "<script></script>")
72 commitHomepageFile(t, cfg.DataPath, "team/nested", "unknown.xyz", "unknown")
73
74 worktree := t.TempDir()
75 runHomepageGit(t, "clone", cfg.DataPath+"/repos/team/nested.git", worktree)
76 runHomepageGitIn(t, worktree, "config", "user.email", "test@example.com")
77 runHomepageGitIn(t, worktree, "config", "user.name", "Test User")
78 runHomepageGitIn(t, worktree, "checkout", "-qb", "feature/with-slash")
79 runHomepageGitIn(t, worktree, "push", "origin", "HEAD")
80
81 router := NewRouter(ctx)
82 request := func(target string) *httptest.ResponseRecorder {
83 w := httptest.NewRecorder()
84 router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
85 return w
86 }
87
88 overview := request("/team/nested")
89 if overview.Code != http.StatusOK {
90 t.Fatalf("overview status = %d: %s", overview.Code, overview.Body.String())
91 }
92 for _, expected := range []string{"Nested project", "https://git.example/team/nested.git", "Copy HTTP clone URL", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
93 if !strings.Contains(overview.Body.String(), expected) {
94 t.Errorf("overview missing %q: %s", expected, overview.Body.String())
95 }
96 }
97
98 empty := request("/team/empty")
99 if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), "repository is empty") {
100 t.Errorf("empty repository response = %d: %s", empty.Code, empty.Body.String())
101 }
102 private := request("/team/private")
103 if private.Code != http.StatusNotFound {
104 t.Errorf("private repository status = %d", private.Code)
105 }
106
107 root := request("/team/nested/@/tree?ref=refs%2Fheads%2Fmain")
108 if root.Code != http.StatusOK {
109 t.Fatalf("root tree status = %d: %s", root.Code, root.Body.String())
110 }
111 if strings.Index(root.Body.String(), ">dir<") > strings.Index(root.Body.String(), ">source.go<") {
112 t.Errorf("directories are not listed before files: %s", root.Body.String())
113 }
114 nested := request("/team/nested/@/tree?ref=refs%2Fheads%2Fmain&path=dir")
115 if nested.Code != http.StatusOK || strings.Index(nested.Body.String(), ">nested<") > strings.Index(nested.Body.String(), ">a.txt<") {
116 t.Errorf("nested tree ordering response = %d: %s", nested.Code, nested.Body.String())
117 }
118 source := request("/team/nested/@/tree?ref=refs%2Fheads%2Fmain&path=source.go")
119 if source.Code != http.StatusOK || strings.Contains(source.Body.String(), "<script>alert") || !strings.Contains(source.Body.String(), "<") || !strings.Contains(source.Body.String(), `data-copy="https://git.example/team/nested/@/raw?path=source.go&ref=refs%2Fheads%2Fmain"`) {
120 t.Errorf("source response = %d: %s", source.Code, source.Body.String())
121 }
122 markdown := request("/team/nested/@/tree?ref=refs%2Fheads%2Fmain&path=docs%2Fguide.md")
123 if markdown.Code != http.StatusOK || !strings.Contains(markdown.Body.String(), "<h1>Guide</h1>") || strings.Contains(markdown.Body.String(), "<script>alert") || !strings.Contains(markdown.Body.String(), "/team/nested/@/tree?path=source.go&ref=refs%2Fheads%2Fmain") || !strings.Contains(markdown.Body.String(), "/team/nested/@/raw?path=images%2Ficon.png&ref=refs%2Fheads%2Fmain") {
124 t.Errorf("markdown response = %d: %s", markdown.Code, markdown.Body.String())
125 }
126 branch := request("/team/nested/@/tree?ref=refs%2Fheads%2Ffeature%2Fwith-slash")
127 if branch.Code != http.StatusOK {
128 t.Errorf("slash branch status = %d: %s", branch.Code, branch.Body.String())
129 }
130 traversal := request("/team/nested/@/tree?ref=refs%2Fheads%2Fmain&path=..%2Fprivate")
131 if traversal.Code != http.StatusNotFound {
132 t.Errorf("traversal status = %d", traversal.Code)
133 }
134
135 png := request("/team/nested/@/raw?ref=refs%2Fheads%2Fmain&path=images%2Ficon.png")
136 if png.Code != http.StatusOK || png.Header().Get("Content-Type") != "image/png" || png.Header().Get("Content-Disposition") != "" || png.Header().Get("X-Content-Type-Options") != "nosniff" || png.Header().Get("Referrer-Policy") != "same-origin" {
137 t.Errorf("PNG response = %d, headers = %#v", png.Code, png.Header())
138 }
139 for _, name := range []string{"unsafe.svg", "unsafe.html", "unknown.xyz"} {
140 response := request("/team/nested/@/raw?ref=refs%2Fheads%2Fmain&path=" + name)
141 if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "application/octet-stream" || !strings.HasPrefix(response.Header().Get("Content-Disposition"), "attachment;") {
142 t.Errorf("unsafe raw %s = %d, headers = %#v", name, response.Code, response.Header())
143 }
144 }
145}