Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 13.7 KiB
1package pages
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "path/filepath"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "rodent.software/vole/core/git"
13 "rodent.software/vole/forge/access"
14 "rodent.software/vole/forge/repo"
15)
16
17const domain = "pages.test"
18
19type fixture struct {
20 t testing.TB
21 root string
22 repos *repo.Store
23 srv *Server
24}
25
26func newFixture(t testing.TB) *fixture {
27 t.Helper()
28 root := t.TempDir()
29 // Access rules live in the instance database; a site's visibility is one of them.
30 acls, err := access.Open(filepath.Join(root, "users.db"))
31 if err != nil {
32 t.Fatal(err)
33 }
34 acls.AuditPath = filepath.Join(root, "audit.log")
35 t.Cleanup(func() { acls.Close() })
36 repos := repo.New(filepath.Join(root, "repos"), "/bin/true", acls)
37 m, err := repos.Create("site", "owner", "a site")
38 if err != nil {
39 t.Fatal(err)
40 }
41 a, err := m.ACL()
42 if err != nil {
43 t.Fatal(err)
44 }
45 a.Default = "read"
46 if err := m.SetACL(a, "test"); err != nil {
47 t.Fatal(err)
48 }
49 srv := New(Config{
50 Domain: domain,
51 Ref: "pages",
52 Procs: 2,
53 CacheBytes: 1 << 20,
54 TipTTL: 20 * time.Millisecond,
55 }, repos)
56 t.Cleanup(func() { srv.Close() })
57 return &fixture{t: t, root: root, repos: repos, srv: srv}
58}
59
60// publish commits files to the pages branch and returns the tip.
61func (f *fixture) publish(files map[string][]byte) string {
62 f.t.Helper()
63 m, err := f.repos.Open("site")
64 if err != nil {
65 f.t.Fatal(err)
66 }
67 g := git.Open(m.Dir)
68 tree, err := git.WriteFlatTree(g, files)
69 if err != nil {
70 f.t.Fatal(err)
71 }
72 old, _ := g.RevParse("refs/heads/pages")
73 commit, err := g.CommitTree(tree, old, "site update")
74 if err != nil {
75 f.t.Fatal(err)
76 }
77 if err := g.UpdateRef("refs/heads/pages", commit); err != nil {
78 f.t.Fatal(err)
79 }
80 return commit
81}
82
83func (f *fixture) get(host, path string, headers ...[2]string) *httptest.ResponseRecorder {
84 f.t.Helper()
85 req := httptest.NewRequest(http.MethodGet, path, nil)
86 req.Host = host
87 for _, h := range headers {
88 req.Header.Set(h[0], h[1])
89 }
90 rec := httptest.NewRecorder()
91 f.srv.ServeHTTP(rec, req)
92 return rec
93}
94
95func TestHostNamesTheRepository(t *testing.T) {
96 t.Parallel()
97 f := newFixture(t)
98 cases := []struct {
99 host string
100 name string
101 ok bool
102 }{
103 {"site." + domain, "site", true},
104 {"site." + domain + ":8080", "site", true},
105 {"SITE." + domain, "site", true},
106 {domain, "", false},
107 {"other.example.com", "", false},
108 {"site.other.test", "", false},
109 {"not a name." + domain, "", false},
110 {"", "", false},
111 }
112 for _, c := range cases {
113 name, ok := f.srv.Host(c.host)
114 if ok != c.ok || name != c.name {
115 t.Errorf("Host(%q) = %q, %v; want %q, %v", c.host, name, ok, c.name, c.ok)
116 }
117 }
118}
119
120func TestServesTheBranchAsASite(t *testing.T) {
121 t.Parallel()
122 f := newFixture(t)
123 commit := f.publish(map[string][]byte{
124 "index.html": []byte("<!doctype html><title>home</title>\n"),
125 "about.html": []byte("<p>about</p>\n"),
126 "style.css": []byte("body{color:red}\n"),
127 "docs/index.html": []byte("<p>docs</p>\n"),
128 "docs/guide/intro.md": []byte("# intro\n"),
129 })
130
131 rec := f.get("site."+domain, "/")
132 if rec.Code != 200 || !strings.Contains(rec.Body.String(), "home") {
133 t.Fatalf("GET / = %d %q", rec.Code, rec.Body.String())
134 }
135 if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
136 t.Fatalf("index Content-Type = %q", ct)
137 }
138 // The ETag is the blob sha, which is what makes invalidation exact.
139 g := git.Open(mustDir(t, f))
140 entries, err := g.LsTreeR(commit)
141 if err != nil {
142 t.Fatal(err)
143 }
144 var indexSHA string
145 for _, e := range entries {
146 if e.Name == "index.html" {
147 indexSHA = e.SHA
148 }
149 }
150 if indexSHA == "" {
151 t.Fatalf("index.html is not in commit %s", commit)
152 }
153 if got := rec.Header().Get("ETag"); got != `"`+indexSHA+`"` {
154 t.Fatalf("ETag = %q, want the blob sha %s", got, indexSHA)
155 }
156 if rec.Header().Get("Last-Modified") == "" {
157 t.Fatal("no Last-Modified header")
158 }
159 if n := rec.Header().Get("Content-Length"); n != strconv.Itoa(rec.Body.Len()) {
160 t.Fatalf("Content-Length = %q, body is %d bytes", n, rec.Body.Len())
161 }
162
163 if rec := f.get("site."+domain, "/style.css"); rec.Code != 200 ||
164 !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
165 t.Fatalf("css = %d %q", rec.Code, rec.Header().Get("Content-Type"))
166 }
167 // A directory serves its index, and a directory URL without a slash redirects
168 // so that relative links inside it still resolve.
169 if rec := f.get("site."+domain, "/docs/"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "docs") {
170 t.Fatalf("GET /docs/ = %d %q", rec.Code, rec.Body.String())
171 }
172 rec = f.get("site."+domain, "/docs")
173 if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/docs/" {
174 t.Fatalf("GET /docs = %d %q", rec.Code, rec.Header().Get("Location"))
175 }
176 // A path with no extension still gets a type, sniffed from the bytes.
177 if rec := f.get("site."+domain, "/docs/guide/intro.md"); rec.Code != 200 {
178 t.Fatalf("markdown = %d", rec.Code)
179 } else if rec.Header().Get("Content-Type") == "" {
180 t.Fatal("no Content-Type for an extension-less type")
181 }
182}
183
184func TestConditionalRequestsDoNotResendTheBody(t *testing.T) {
185 t.Parallel()
186 f := newFixture(t)
187 f.publish(map[string][]byte{"index.html": []byte("<h1>hello</h1>\n")})
188
189 first := f.get("site."+domain, "/")
190 if first.Code != 200 {
191 t.Fatalf("first GET = %d", first.Code)
192 }
193 etag := first.Header().Get("ETag")
194 if etag == "" {
195 t.Fatal("no ETag")
196 }
197 second := f.get("site."+domain, "/", [2]string{"If-None-Match", etag})
198 if second.Code != http.StatusNotModified {
199 t.Fatalf("If-None-Match = %d, want 304", second.Code)
200 }
201 if second.Body.Len() != 0 {
202 t.Fatalf("304 carried %d bytes", second.Body.Len())
203 }
204 // A weak or different tag gets the file, not a 304.
205 if rec := f.get("site."+domain, "/", [2]string{"If-None-Match", `"nope"`}); rec.Code != 200 {
206 t.Fatalf("stale If-None-Match = %d, want 200", rec.Code)
207 }
208 // Last-Modified is the fallback for clients that do not send an ETag.
209 if rec := f.get("site."+domain, "/", [2]string{"If-Modified-Since", first.Header().Get("Last-Modified")}); rec.Code != http.StatusNotModified {
210 t.Fatalf("If-Modified-Since = %d, want 304", rec.Code)
211 }
212 if rec := f.get("site."+domain, "/", [2]string{"If-Modified-Since", time.Now().Add(time.Hour).UTC().Format(http.TimeFormat)}); rec.Code != http.StatusNotModified {
213 t.Fatalf("If-Modified-Since in the future = %d, want 304", rec.Code)
214 }
215}
216
217func TestPathTraversalIsJustAMissingPage(t *testing.T) {
218 t.Parallel()
219 f := newFixture(t)
220 f.publish(map[string][]byte{
221 "index.html": []byte("home\n"),
222 "odd..name.txt": []byte("dots in a name are fine\n"),
223 })
224 bad := []string{
225 "/../../etc/passwd",
226 "/..%2f..%2fetc/passwd",
227 "/docs/../../etc/passwd",
228 "/%2e%2e/%2e%2e/etc/passwd",
229 "/index.html%00.png",
230 "/sub\\..\\..\\index.html",
231 }
232 for _, path := range bad {
233 if rec := f.get("site."+domain, path); rec.Code != http.StatusNotFound {
234 t.Errorf("GET %q = %d, want 404", path, rec.Code)
235 }
236 }
237 // A name containing dots is not a traversal and must still be served.
238 if rec := f.get("site."+domain, "/odd..name.txt"); rec.Code != 200 {
239 t.Fatalf("GET /odd..name.txt = %d, want 200", rec.Code)
240 }
241 // A traversal that stays inside the site resolves to a real page rather than
242 // escaping: path cleaning happens before the tree lookup.
243 if rec := f.get("site."+domain, "/docs/../index.html"); rec.Code != 200 {
244 t.Fatalf("GET /docs/../index.html = %d, want 200 (cleaned to /index.html)", rec.Code)
245 }
246}
247
248func TestNoSiteNoPages(t *testing.T) {
249 t.Parallel()
250 f := newFixture(t)
251 // No pages branch at all.
252 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusNotFound {
253 t.Fatalf("repo without a pages branch = %d, want 404", rec.Code)
254 }
255 // Unknown repository.
256 f.publish(map[string][]byte{"index.html": []byte("x\n")})
257 if rec := f.get("nosuch."+domain, "/"); rec.Code != http.StatusNotFound {
258 t.Fatalf("unknown repo = %d, want 404", rec.Code)
259 }
260 // A private repository has no site: there is no reader to authenticate.
261 m, err := f.repos.Open("site")
262 if err != nil {
263 t.Fatal(err)
264 }
265 a, err := m.ACL()
266 if err != nil {
267 t.Fatal(err)
268 }
269 a.Default = "none"
270 if err := m.SetACL(a, "test"); err != nil {
271 t.Fatal(err)
272 }
273 // Authorization is resolved once per memo window, so a visibility change
274 // lands within that window rather than on the next request. Waiting here is
275 // the test being honest about the documented behaviour.
276 time.Sleep(40 * time.Millisecond)
277 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusNotFound {
278 t.Fatalf("private repo = %d, want 404", rec.Code)
279 }
280 // A default that grants more than read still lets an anonymous reader read:
281 // the ACL is a role, and write implies read. Nobody can write over HTTP
282 // anyway, and SSH has no use for this default.
283 a.Default = "maintain"
284 if err := m.SetACL(a, "test"); err != nil {
285 t.Fatal(err)
286 }
287 time.Sleep(40 * time.Millisecond)
288 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
289 t.Fatalf("write-default repo = %d, want 200", rec.Code)
290 }
291 // ... and the forge UI is untouched by any of this.
292 req := httptest.NewRequest(http.MethodGet, "/-/healthz", nil)
293 req.Host = "127.0.0.1:8080"
294 rec := httptest.NewRecorder()
295 f.srv.ServeHTTP(rec, req)
296 if rec.Code != http.StatusNotFound {
297 // The pages handler owns only its own host; anything else is not its
298 // business, and the mux answers. 404 here is the pages server saying so.
299 t.Fatalf("non-pages host = %d", rec.Code)
300 }
301 if _, ok := f.srv.Host("127.0.0.1:8080"); ok {
302 t.Fatal("the pages server claimed a host it does not own")
303 }
304}
305
306func TestNewCommitIsLiveAfterTheTipTTL(t *testing.T) {
307 t.Parallel()
308 f := newFixture(t)
309 f.publish(map[string][]byte{"index.html": []byte("version one\n")})
310 if body := f.get("site."+domain, "/").Body.String(); !strings.Contains(body, "version one") {
311 t.Fatalf("v1 body = %q", body)
312 }
313 f.publish(map[string][]byte{"index.html": []byte("version two\n")})
314 // Inside the TTL the old answer is still served: that is the price of not
315 // asking git on every request, and it is one second in production.
316 time.Sleep(40 * time.Millisecond)
317 if body := f.get("site."+domain, "/").Body.String(); !strings.Contains(body, "version two") {
318 t.Fatalf("after the TTL the site still says %q", body)
319 }
320}
321
322func mustDir(t testing.TB, f *fixture) string {
323 t.Helper()
324 m, err := f.repos.Open("site")
325 if err != nil {
326 t.Fatal(err)
327 }
328 return m.Dir
329}
330
331func TestCacheEvictsAndDropsCommits(t *testing.T) {
332 t.Parallel()
333 c := newLRU(1024) // maxEntry is an eighth of that: 128 bytes
334 for i := 0; i < 12; i++ {
335 c.put(&cacheEntry{key: "k" + strconv.Itoa(i), commit: "c1", body: make([]byte, 100)})
336 }
337 if _, ok := c.get("k0"); ok {
338 t.Fatal("the oldest entry survived an eviction")
339 }
340 if _, ok := c.get("k11"); !ok {
341 t.Fatal("the newest entry was evicted")
342 }
343 if entries, used := c.stats(); used > 1024 {
344 t.Fatalf("cache holds %d bytes, over its %d budget (%d entries)", used, 1024, entries)
345 }
346 c.put(&cacheEntry{key: "big", commit: "c1", body: make([]byte, 4096)})
347 if _, ok := c.get("big"); ok {
348 t.Fatal("an entry larger than maxEntry was cached")
349 }
350 c.setLookup("c1\x00index.html", treeEntry{sha: "x", typ: "blob"})
351 c.setLookup("c2\x00index.html", treeEntry{sha: "y", typ: "blob"})
352 c.dropCommit("c1")
353 if _, ok := c.lookup("c1\x00index.html"); ok {
354 t.Fatal("dropCommit left a lookup behind")
355 }
356 if _, ok := c.lookup("c2\x00index.html"); !ok {
357 t.Fatal("dropCommit removed another commit's lookup")
358 }
359}
360
361// Benchmarks for the read path. They are not part of the gate; run them when
362// changing the cache or the pool:
363//
364// go test ./pages/ -bench . -benchtime=1s -run XXX
365func benchFixture(b *testing.B) *fixture {
366 b.Helper()
367 f := newFixture(b)
368 f.publish(map[string][]byte{
369 "index.html": []byte(strings.Repeat("<p>page body</p>\n", 256)),
370 "docs/index.html": []byte(strings.Repeat("<p>docs</p>\n", 256)),
371 })
372 return f
373}
374
375// BenchmarkPageWarm measures a hit in the blob and lookup caches.
376func BenchmarkPageWarm(b *testing.B) {
377 f := benchFixture(b)
378 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
379 b.Fatalf("warm-up request = %d", rec.Code)
380 }
381 b.ResetTimer()
382 for i := 0; i < b.N; i++ {
383 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
384 b.Fatalf("request %d = %d", i, rec.Code)
385 }
386 }
387}
388
389// BenchmarkPageCold measures the same request with the cache emptied, so every
390// op walks the trees through the cat-file pool and reads the blob.
391func BenchmarkPageCold(b *testing.B) {
392 f := benchFixture(b)
393 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
394 b.Fatalf("warm-up request = %d", rec.Code)
395 }
396 b.ResetTimer()
397 for i := 0; i < b.N; i++ {
398 f.srv.cache = newLRU(1 << 20)
399 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
400 b.Fatalf("request %d = %d", i, rec.Code)
401 }
402 }
403}
404
405// TestOversizedPageIsRefused covers the size cap on a site blob. A site is
406// served to anyone who can reach the hostname, so one pushed file must not be
407// able to make every request for it allocate: the answer is a 413 that names the
408// limit, and the pool keeps working afterwards.
409func TestOversizedPageIsRefused(t *testing.T) {
410 t.Parallel()
411 f := newFixture(t)
412 f.publish(map[string][]byte{
413 "index.html": []byte("<!doctype html><title>home</title>\n"),
414 "huge.bin": make([]byte, maxPageBytes+1),
415 })
416
417 rec := f.get("site."+domain, "/huge.bin")
418 if rec.Code != http.StatusRequestEntityTooLarge {
419 t.Fatalf("oversized page = %d, want 413", rec.Code)
420 }
421 if !strings.Contains(rec.Body.String(), "larger than") {
422 t.Fatalf("413 did not explain itself: %q", rec.Body.String())
423 }
424
425 // The refused object left a cat-file process killed mid-stream; the next read
426 // must start a fresh one rather than see a half-consumed stream.
427 if rec := f.get("site."+domain, "/"); rec.Code != http.StatusOK {
428 t.Fatalf("page after a refused one = %d", rec.Code)
429 }
430}
431