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.

Blame · server/pages_test.go

Line attribution on main · 255 line(s)

d3296cda
Keenan Nemetz
17d ago
1 package 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
6 import (
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
d37d301f
Keenan Nemetz
16h ago
15 "rodent.software/vole/forge/config"
d3296cda
Keenan Nemetz
17d ago
16 )
17
18 const 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.
23 var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
24 return http.ErrUseLastResponse
25 }}
26
27 func 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.
39 func (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.
60 func (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
68 func TestSiteIsServedFromThePushedBranch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
69 t.Parallel()
d3296cda
Keenan Nemetz
17d ago
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.
ed03b3d1
Keenan Nemetz
3h ago
105 if code, body := f.get("/" + f.repo); code != 200 || !strings.Contains(body, f.repo) {
d3296cda
Keenan Nemetz
17d ago
106 t.Fatalf("forge UI on the plain host = %d", code)
107 }
108 }
109
816cc543
Keenan Nemetz
11d ago
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.
114 func TestHeaderOffersSiteOnlyAfterADeploy(t *testing.T) {
115 t.Parallel()
116 f := pagesFixture(t)
117 f.publish(f.repo)
118
ed03b3d1
Keenan Nemetz
3h ago
119 if code, body := f.get("/" + f.repo); code != 200 || strings.Contains(body, "Site</a>") {
816cc543
Keenan Nemetz
11d ago
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
ed03b3d1
Keenan Nemetz
3h ago
126 code, body := f.get("/" + f.repo + "/code")
816cc543
Keenan Nemetz
11d ago
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
520470e1
Keenan Nemetz
16d ago
135 // TestSitePushIsLiveWithinFiveSeconds checks that a push is live within five
136 // seconds, and it is measured rather than assumed: push, then poll the site.
d3296cda
Keenan Nemetz
17d ago
137 func TestSitePushIsLiveWithinFiveSeconds(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
138 t.Parallel()
d3296cda
Keenan Nemetz
17d ago
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
179 func TestSiteConditionalRequests(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
180 t.Parallel()
d3296cda
Keenan Nemetz
17d ago
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
201 func TestSiteIsNotAnEscapeHatch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
202 t.Parallel()
d3296cda
Keenan Nemetz
17d ago
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
219 func TestSiteNeedsAPublicRepositoryAndAReferencedBranch(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
220 t.Parallel()
d3296cda
Keenan Nemetz
17d ago
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 }