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 · 50m ago 4b8c9e1d
history blame raw 8.2 KiB
1package server_test
2
3// Site hosting end to end: a branch pushed over SSH is served as a website on its
4// own hostname, with no export step and no restart.
5
6import (
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "rodent.software/vole/forge/config"
16)
17
18const pagesDomain = "pages.test"
19
20// noRedirect is a client that reports a redirect instead of chasing it. The
21// default client would drop the site's Host header on the way, which turns a
22// redirect test into a test of the forge UI.
23var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
24 return http.ErrUseLastResponse
25}}
26
27func pagesFixture(t *testing.T) *fixture {
28 t.Helper()
29 return newFixtureWith(t, func(c *config.Config) {
30 c.PagesDomain = pagesDomain
31 c.PagesRef = "pages"
32 c.PagesProcs = 2
33 c.PagesCacheBytes = 1 << 20
34 })
35}
36
37// siteGet requests a page from a site host while still talking to the test
38// listener, which is what a real request with a Host header does.
39func (f *fixture) siteGet(host, path string, headers ...[2]string) (int, string, http.Header) {
40 f.t.Helper()
41 req, err := http.NewRequest(http.MethodGet, f.http.URL+path, nil)
42 if err != nil {
43 f.t.Fatal(err)
44 }
45 req.Host = host
46 for _, h := range headers {
47 req.Header.Set(h[0], h[1])
48 }
49 // No redirect following: a 301 is part of the contract being tested.
50 resp, err := noRedirect.Do(req)
51 if err != nil {
52 f.t.Fatal(err)
53 }
54 defer resp.Body.Close()
55 body, _ := io.ReadAll(resp.Body)
56 return resp.StatusCode, string(body), resp.Header
57}
58
59// pushPages commits files to the site branch, the way a user would.
60func (f *fixture) pushPages(t *testing.T, files map[string]string) {
61 t.Helper()
62 work := workRepo(t, files)
63 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/pages"); err != nil {
64 t.Fatalf("push pages: %v\n%s", err, errs)
65 }
66}
67
68func TestSiteIsServedFromThePushedBranch(t *testing.T) {
69 t.Parallel()
70 f := pagesFixture(t)
71 f.publish(f.repo)
72 f.pushPages(t, map[string]string{
73 "index.html": "<!doctype html><h1>hello site</h1>\n",
74 "assets/style.css": "body { color: #123 }\n",
75 "docs/index.html": "<p>docs home</p>\n",
76 })
77
78 host := f.repo + "." + pagesDomain
79 code, body, hdr := f.siteGet(host, "/")
80 if code != 200 {
81 t.Fatalf("GET / = %d: %s", code, body)
82 }
83 if !strings.Contains(body, "hello site") {
84 t.Fatalf("index body = %q", body)
85 }
86 if ct := hdr.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
87 t.Fatalf("Content-Type = %q", ct)
88 }
89 if hdr.Get("ETag") == "" || hdr.Get("Last-Modified") == "" {
90 t.Fatalf("missing validators: ETag=%q Last-Modified=%q", hdr.Get("ETag"), hdr.Get("Last-Modified"))
91 }
92 if code, body, hdr := f.siteGet(host, "/assets/style.css"); code != 200 ||
93 !strings.HasPrefix(hdr.Get("Content-Type"), "text/css") {
94 t.Fatalf("css = %d %q (%q)", code, hdr.Get("Content-Type"), body)
95 }
96 // A directory URL without its slash redirects, so relative links keep working.
97 if code, _, hdr := f.siteGet(host, "/docs"); code != http.StatusMovedPermanently || hdr.Get("Location") != "/docs/" {
98 t.Fatalf("GET /docs = %d %q", code, hdr.Get("Location"))
99 }
100 if code, body, _ := f.siteGet(host, "/docs/"); code != 200 || !strings.Contains(body, "docs home") {
101 t.Fatalf("GET /docs/ = %d %q", code, body)
102 }
103
104 // The forge UI keeps its own host: the site only owns its subdomain.
105 if code, body := f.get("/" + f.repo); code != 200 || !strings.Contains(body, f.repo) {
106 t.Fatalf("forge UI on the plain host = %d", code)
107 }
108}
109
110// TestHeaderOffersSiteOnlyAfterADeploy: the Site link is a promise the instance
111// can keep. Before the pages branch exists it would lead to a 404, so the
112// repository header does not offer it; after the first deploy it appears on
113// every repository tab.
114func TestHeaderOffersSiteOnlyAfterADeploy(t *testing.T) {
115 t.Parallel()
116 f := pagesFixture(t)
117 f.publish(f.repo)
118
119 if code, body := f.get("/" + f.repo); code != 200 || strings.Contains(body, "Site</a>") {
120 t.Fatalf("header offers Site before a deploy: %d", code)
121 }
122
123 f.pushPages(t, map[string]string{"index.html": "hello\n"})
124
125 want := "http://" + f.repo + "." + pagesDomain
126 code, body := f.get("/" + f.repo + "/code")
127 if code != 200 {
128 t.Fatalf("GET code = %d", code)
129 }
130 if !strings.Contains(body, `href="`+want+`"`) || !strings.Contains(body, "Site</a>") {
131 t.Fatalf("code header does not link the deployed site %q", want)
132 }
133}
134
135// TestSitePushIsLiveWithinFiveSeconds checks that a push is live within five
136// seconds, and it is measured rather than assumed: push, then poll the site.
137func TestSitePushIsLiveWithinFiveSeconds(t *testing.T) {
138 t.Parallel()
139 f := pagesFixture(t)
140 f.publish(f.repo)
141 work := workRepo(t, map[string]string{"index.html": "version one\n"})
142 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/pages"); err != nil {
143 t.Fatalf("push v1: %v\n%s", err, errs)
144 }
145
146 host := f.repo + "." + pagesDomain
147 if code, body, _ := f.siteGet(host, "/"); code != 200 || !strings.Contains(body, "version one") {
148 t.Fatalf("v1 = %d %q", code, body)
149 }
150
151 // Second push from the same working copy: a site branch is a branch, so it
152 // moves by fast-forward as usual.
153 if err := os.WriteFile(filepath.Join(work, "index.html"), []byte("version two\n"), 0o644); err != nil {
154 t.Fatal(err)
155 }
156 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
157 t.Fatalf("add: %s", errs)
158 }
159 if _, errs, err := runGit(t, work, "commit", "-qm", "v2"); err != nil {
160 t.Fatalf("commit: %s", errs)
161 }
162 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/pages"); err != nil {
163 t.Fatalf("push v2: %v\n%s", err, errs)
164 }
165
166 deadline := time.Now().Add(5 * time.Second)
167 for {
168 code, body, _ := f.siteGet(host, "/")
169 if code == 200 && strings.Contains(body, "version two") {
170 break
171 }
172 if time.Now().After(deadline) {
173 t.Fatalf("the pushed site was not live within 5s; still serving %q", body)
174 }
175 time.Sleep(100 * time.Millisecond)
176 }
177}
178
179func TestSiteConditionalRequests(t *testing.T) {
180 t.Parallel()
181 f := pagesFixture(t)
182 f.publish(f.repo)
183 f.pushPages(t, map[string]string{"index.html": "conditional\n"})
184 host := f.repo + "." + pagesDomain
185
186 code, _, hdr := f.siteGet(host, "/")
187 if code != 200 {
188 t.Fatalf("first GET = %d", code)
189 }
190 etag := hdr.Get("ETag")
191 code, body, _ := f.siteGet(host, "/", [2]string{"If-None-Match", etag})
192 if code != http.StatusNotModified || body != "" {
193 t.Fatalf("If-None-Match = %d with %d bytes, want an empty 304", code, len(body))
194 }
195 code, _, _ = f.siteGet(host, "/", [2]string{"If-Modified-Since", hdr.Get("Last-Modified")})
196 if code != http.StatusNotModified {
197 t.Fatalf("If-Modified-Since = %d, want 304", code)
198 }
199}
200
201func TestSiteIsNotAnEscapeHatch(t *testing.T) {
202 t.Parallel()
203 f := pagesFixture(t)
204 f.publish(f.repo)
205 f.pushPages(t, map[string]string{"index.html": "safe\n"})
206 host := f.repo + "." + pagesDomain
207
208 for _, path := range []string{
209 "/../../etc/passwd",
210 "/%2e%2e/%2e%2e/etc/passwd",
211 "/../../../home/keenan/.ssh/id_ed25519",
212 } {
213 if code, body, _ := f.siteGet(host, path); code != http.StatusNotFound {
214 t.Fatalf("GET %q = %d, body %q", path, code, body)
215 }
216 }
217}
218
219func TestSiteNeedsAPublicRepositoryAndAReferencedBranch(t *testing.T) {
220 t.Parallel()
221 f := pagesFixture(t)
222 // Published, but no pages branch yet.
223 f.publish(f.repo)
224 host := f.repo + "." + pagesDomain
225 if code, _, _ := f.siteGet(host, "/"); code != http.StatusNotFound {
226 t.Fatalf("no pages branch = %d, want 404", code)
227 }
228
229 // A site branch on a private repository is not served: an anonymous reader
230 // has no way to authenticate, so 404 rather than a login that cannot exist.
231 f.pushPages(t, map[string]string{"index.html": "private\n"})
232 m, err := f.srv.Repos.Open(f.repo)
233 if err != nil {
234 t.Fatal(err)
235 }
236 a, err := m.ACL()
237 if err != nil {
238 t.Fatal(err)
239 }
240 a.Default = "none"
241 if err := m.SetACL(a, "test"); err != nil {
242 t.Fatal(err)
243 }
244 // Site visibility is resolved once per memo window (one second), so the
245 // change lands within that window rather than on the very next request.
246 time.Sleep(1100 * time.Millisecond)
247 if code, _, _ := f.siteGet(host, "/"); code != http.StatusNotFound {
248 t.Fatalf("private repo site = %d, want 404", code)
249 }
250
251 // An unknown site name answers the same way.
252 if code, _, _ := f.siteGet("nosuchrepo."+pagesDomain, "/"); code != http.StatusNotFound {
253 t.Fatalf("unknown repo site = %d, want 404", code)
254 }
255}
256