repos_test.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "github.com/go-chi/chi/v5"
15
16 "hearthforge/internal/config"
17 "hearthforge/internal/db"
18 "hearthforge/internal/gitcmd"
19 "hearthforge/internal/highlight"
20 "hearthforge/internal/markdown"
21)
22
23// repoTestServer returns a router with the repo routes and an admin user.
24func repoTestServer(t *testing.T) (http.Handler, *Server, *db.SessionUser) {
25 t.Helper()
26 ctx := context.Background()
27 dataDir := t.TempDir()
28 d, err := db.Open(filepath.Join(dataDir, "hearthforge.db"))
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { d.Close() })
33 if _, err := d.InitAdmin(ctx, "hunter2"); err != nil {
34 t.Fatal(err)
35 }
36 admin, err := d.UserByName(ctx, db.AdminUsername)
37 if err != nil || admin == nil {
38 t.Fatalf("admin lookup failed: %v", err)
39 }
40
41 cfg := &config.Config{
42 DataDir: dataDir,
43 OwnerDisplayName: "Admin",
44 BaseURL: "http://localhost:3000",
45 SSHPort: 2222,
46 InlineMaxBytes: 512 << 10,
47 MaxRenderBytes: 10 << 20,
48 MaxTitleBytes: 500,
49 MaxTextBodyBytes: 100000,
50 CommitterName: "Admin",
51 CommitterEmail: "admin@localhost",
52 RateLimitDisabled: true,
53 }
54 cfg.SSHHostKeyPath = filepath.Join(dataDir, "ssh_host_key")
55 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
56 t.Fatal(err)
57 }
58 // Commits made through the UI are ssh-signed, so the host key must exist.
59 if _, err := exec.LookPath("git"); err != nil {
60 t.Skip("git not installed")
61 }
62 keygen := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", cfg.SSHHostKeyPath, "-C", "test")
63 if out, err := keygen.CombinedOutput(); err != nil {
64 t.Skipf("ssh-keygen unavailable: %v: %s", err, out)
65 }
66 s := &Server{
67 Cfg: cfg,
68 DB: d,
69 MD: markdown.New(),
70 HL: highlight.New(cfg.InlineMaxBytes),
71 Git: gitcmd.New(cfg),
72 }
73 r := chi.NewRouter()
74 s.repoRoutes(r)
75 return r, s, &db.SessionUser{ID: admin.ID, Username: db.AdminUsername, IsAdmin: true}
76}
77
78func TestRepoCreateBrowseAndEdit(t *testing.T) {
79 h, s, admin := repoTestServer(t)
80
81 res := do(t, h, admin, "POST", "/new", url.Values{
82 "name": {"my-repo"}, "description": {"A demo"}, "default_branch": {"main"},
83 })
84 if res.Code != http.StatusFound {
85 t.Fatalf("create repo status = %d, body %s", res.Code, res.Body.String())
86 }
87 if got := res.Header().Get("Location"); got != "/my-repo" {
88 t.Fatalf("create repo redirected to %q", got)
89 }
90 bare := filepath.Join(s.Cfg.ReposDir(), "my-repo.git")
91 if _, err := os.Stat(filepath.Join(bare, "HEAD")); err != nil {
92 t.Fatalf("bare repo missing on disk: %v", err)
93 }
94
95 // The UI create-file route must produce a real commit.
96 res = do(t, h, admin, "POST", "/my-repo/new-file/main", url.Values{
97 "path": {"README.md"}, "content": {"# Hello\n\nworld\n"}, "message": {"Add README"},
98 })
99 if res.Code != http.StatusFound {
100 t.Fatalf("create file status = %d, body %s", res.Code, res.Body.String())
101 }
102 commitURL := res.Header().Get("Location")
103 if !strings.HasPrefix(commitURL, "/my-repo/commit/") {
104 t.Fatalf("create file redirected to %q", commitURL)
105 }
106 commits, err := s.Git.Log(context.Background(), "my-repo", "main", 10, 0)
107 if err != nil || len(commits) != 1 {
108 t.Fatalf("log = %v, err %v", commits, err)
109 }
110 if commits[0].Subject != "Add README" {
111 t.Errorf("commit subject = %q", commits[0].Subject)
112 }
113
114 res = do(t, h, admin, "GET", "/my-repo", nil)
115 body := res.Body.String()
116 if res.Code != http.StatusOK {
117 t.Fatalf("home status = %d", res.Code)
118 }
119 if !strings.Contains(body, "README.md") {
120 t.Error("repo home did not list the file")
121 }
122 if !strings.Contains(body, "<h1") || !strings.Contains(body, "Hello") {
123 t.Error("repo home did not render the README")
124 }
125
126 res = do(t, h, admin, "GET", "/my-repo/blob/main/README.md", nil)
127 if res.Code != http.StatusOK {
128 t.Fatalf("blob status = %d", res.Code)
129 }
130 if !strings.Contains(res.Body.String(), "markdown-body") {
131 t.Error("blob view did not render the markdown body")
132 }
133
134 res = do(t, h, admin, "GET", "/my-repo/blob/main/notes.txt", nil)
135 if res.Code != http.StatusNotFound {
136 t.Fatalf("missing blob status = %d, want 404", res.Code)
137 }
138
139 res = do(t, h, admin, "GET", "/my-repo/commits/main", nil)
140 if res.Code != http.StatusOK {
141 t.Fatalf("commit log status = %d", res.Code)
142 }
143 if !strings.Contains(res.Body.String(), "Add README") {
144 t.Error("commit log did not show the commit")
145 }
146
147 res = do(t, h, admin, "GET", commitURL, nil)
148 if res.Code != http.StatusOK {
149 t.Fatalf("commit detail status = %d", res.Code)
150 }
151
152 res = do(t, h, admin, "GET", "/my-repo/raw/main/README.md", nil)
153 if res.Code != http.StatusOK {
154 t.Fatalf("raw status = %d", res.Code)
155 }
156 if got := res.Body.String(); got != "# Hello\n\nworld\n" {
157 t.Errorf("raw body = %q", got)
158 }
159}
160
161func TestRepoHighlightedBlob(t *testing.T) {
162 h, _, admin := repoTestServer(t)
163 do(t, h, admin, "POST", "/new", url.Values{"name": {"code"}, "default_branch": {"main"}})
164 do(t, h, admin, "POST", "/code/new-file/main", url.Values{
165 "path": {"main.go"}, "content": {"package main\n"},
166 })
167
168 res := do(t, h, admin, "GET", "/code/blob/main/main.go", nil)
169 if res.Code != http.StatusOK {
170 t.Fatalf("blob status = %d", res.Code)
171 }
172 body := res.Body.String()
173 if !strings.Contains(body, "code-wrapper") {
174 t.Error("blob view did not render the highlighted table")
175 }
176 if !strings.Contains(body, "package") {
177 t.Error("blob view did not contain the file contents")
178 }
179}
180
181func TestRepoPrivateHiddenFromAnonymous(t *testing.T) {
182 h, _, admin := repoTestServer(t)
183 do(t, h, admin, "POST", "/new", url.Values{"name": {"hidden"}, "is_private": {"1"}})
184
185 if res := do(t, h, nil, "GET", "/hidden", nil); res.Code != http.StatusNotFound {
186 t.Fatalf("anonymous status = %d, want 404", res.Code)
187 }
188 if res := do(t, h, admin, "GET", "/hidden", nil); res.Code != http.StatusOK {
189 t.Fatalf("admin status = %d, want 200", res.Code)
190 }
191}
192
193// An empty repo, an unknown branch and a stale `after` cursor all render an
194// empty commit list.
195func TestCommitLogToleratesMissingRefs(t *testing.T) {
196 h, _, admin := repoTestServer(t)
197 if res := do(t, h, admin, "POST", "/new", url.Values{
198 "name": {"empty-anvil"}, "default_branch": {"main"},
199 }); res.Code != http.StatusFound {
200 t.Fatalf("create repo status = %d", res.Code)
201 }
202 for _, target := range []string{
203 "/empty-anvil/commits/main",
204 "/empty-anvil/commits/nope",
205 "/empty-anvil/commits/main?after=e4bdf91390fbb8d693bf32de298f3a5c7d766106",
206 } {
207 if res := do(t, h, admin, "GET", target, nil); res.Code != http.StatusOK {
208 t.Errorf("GET %s = %d, want 200", target, res.Code)
209 }
210 }
211}
212
213// chi keeps %2F encoded, so a branch name with a slash only works if the
214// handlers unescape the {ref} parameter.
215func TestBranchNameWithSlash(t *testing.T) {
216 h, _, admin := repoTestServer(t)
217 if res := do(t, h, admin, "POST", "/new", url.Values{
218 "name": {"forge-core"}, "default_branch": {"main"},
219 }); res.Code != http.StatusFound {
220 t.Fatalf("create repo status = %d", res.Code)
221 }
222 if res := do(t, h, admin, "POST", "/forge-core/new-file/main", url.Values{
223 "path": {"README.md"}, "content": {"hi\n"}, "message": {"init"},
224 }); res.Code != http.StatusFound {
225 t.Fatalf("create file status = %d", res.Code)
226 }
227 if res := do(t, h, admin, "POST", "/forge-core/branches/create", url.Values{
228 "name": {"feature/widgets"}, "source_ref": {"main"},
229 }); res.Code != http.StatusFound {
230 t.Fatalf("create branch status = %d", res.Code)
231 }
232 for _, target := range []string{
233 "/forge-core/tree/feature%2Fwidgets",
234 "/forge-core/commits/feature%2Fwidgets",
235 "/forge-core/blob/feature%2Fwidgets/README.md",
236 } {
237 res := do(t, h, admin, "GET", target, nil)
238 if res.Code != http.StatusOK {
239 t.Errorf("GET %s = %d, want 200", target, res.Code)
240 }
241 }
242}
243
244// refParam decodes exactly once. chi matches on the escaped path, so a route
245// parameter always arrives percent-encoded and "%2F" survives as a ref
246// separator. A name that is already plain must not be decoded a second time.
247func TestRefParamDecodesOnce(t *testing.T) {
248 r := chi.NewRouter()
249 var gotRef, gotPath string
250 r.Get("/{repo}/blob/{ref}/*", func(w http.ResponseWriter, req *http.Request) {
251 gotRef, gotPath = refParam(req, "ref"), refParam(req, "*")
252 })
253 cases := []struct{ target, ref, path string }{
254 // A branch name with a slash only survives the route escaped.
255 {"/x/blob/feature%2Fwidgets/docs/a.md", "feature/widgets", "docs/a.md"},
256 // "%2e" is the client asking for ".", not a literal percent sequence.
257 {"/x/blob/main/a%2e", "main", "a."},
258 // A file really named "a%2e" is requested double-escaped.
259 {"/x/blob/main/a%252e", "main", "a%2e"},
260 {"/x/blob/main/plain.txt", "main", "plain.txt"},
261 }
262 for _, c := range cases {
263 gotRef, gotPath = "", ""
264 r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, c.target, nil))
265 if gotRef != c.ref || gotPath != c.path {
266 t.Errorf("%s: ref = %q, path = %q, want %q, %q", c.target, gotRef, gotPath, c.ref, c.path)
267 }
268 }
269}
270
271func TestRawDocumentTypesAreSandboxed(t *testing.T) {
272 h, _, admin := repoTestServer(t)
273 do(t, h, admin, "POST", "/new", url.Values{"name": {"docs"}, "default_branch": {"main"}})
274 do(t, h, admin, "POST", "/docs/new-file/main", url.Values{
275 "path": {"logo.svg"},
276 "content": {`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`},
277 })
278 do(t, h, admin, "POST", "/docs/new-file/main", url.Values{
279 "path": {"page.html"}, "content": {"<!doctype html><script>alert(1)</script>"},
280 })
281 do(t, h, admin, "POST", "/docs/new-file/main", url.Values{
282 "path": {"notes.txt"}, "content": {"plain\n"},
283 })
284
285 for _, tc := range []struct {
286 path, wantType string
287 sandboxed bool
288 }{
289 {"/docs/raw/main/logo.svg", "image/svg+xml", true},
290 {"/docs/raw/main/page.html", "text/html", true},
291 {"/docs/raw/main/notes.txt", "text/plain", false},
292 } {
293 res := do(t, h, admin, "GET", tc.path, nil)
294 if res.Code != http.StatusOK {
295 t.Fatalf("%s: status = %d", tc.path, res.Code)
296 }
297 if ct := res.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.wantType) {
298 t.Errorf("%s: Content-Type = %q, want prefix %q", tc.path, ct, tc.wantType)
299 }
300 csp := res.Header().Get("Content-Security-Policy")
301 if tc.sandboxed && !strings.Contains(csp, "sandbox;") {
302 t.Errorf("%s: missing sandbox CSP, got %q", tc.path, csp)
303 }
304 if !tc.sandboxed && csp != "" {
305 t.Errorf("%s: unexpected CSP %q", tc.path, csp)
306 }
307 }
308}
309
310func TestIsDocumentType(t *testing.T) {
311 for in, want := range map[string]bool{
312 "text/html; charset=utf-8": true,
313 "IMAGE/SVG+XML": true,
314 "application/rss+xml": true,
315 "text/plain": false,
316 "image/png": false,
317 "application/json": false,
318 } {
319 if got := isDocumentType(in); got != want {
320 t.Errorf("isDocumentType(%q) = %v, want %v", in, got, want)
321 }
322 }
323}
324