e2e/pages_test.go
278 lines · 10834 bytes
3 symbols in this file
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "regexp"
12 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16)
17
18// pagesGet fetches a path with a pages Host header against the instance.
19func (i *instance) pagesGet(t *testing.T, host, path string) (*http.Response, string) {
20 t.Helper()
21 req, err := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path), nil)
22 if err != nil {
23 t.Fatal(err)
24 }
25 req.Host = host
26 resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
27 return http.ErrUseLastResponse
28 }}).Do(req)
29 if err != nil {
30 t.Fatal(err)
31 }
32 defer resp.Body.Close()
33 body, _ := io.ReadAll(resp.Body)
34 return resp, string(body)
35}
36
37// fakeDNS answers every TXT query with the string in txt (none when empty),
38// standing in for the challenge record during domain verification.
39func fakeDNS(t *testing.T, txt *atomic.Value) string {
40 t.Helper()
41 pc, err := net.ListenPacket("udp", "127.0.0.1:0")
42 if err != nil {
43 t.Fatal(err)
44 }
45 t.Cleanup(func() { pc.Close() })
46 go func() {
47 buf := make([]byte, 512)
48 for {
49 n, addr, err := pc.ReadFrom(buf)
50 if err != nil {
51 return
52 }
53 q := buf[:n]
54 if len(q) < 12 {
55 continue
56 }
57 i := 12
58 for i < len(q) && q[i] != 0 {
59 i += int(q[i]) + 1
60 }
61 i += 5 // name terminator + qtype + qclass
62 if i > len(q) {
63 continue
64 }
65 val, _ := txt.Load().(string)
66 resp := []byte{q[0], q[1], 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0}
67 if val != "" {
68 resp[7] = 1
69 }
70 resp = append(resp, q[12:i]...)
71 if val != "" {
72 resp = append(resp, 0xC0, 0x0C, 0, 16, 0, 1, 0, 0, 0, 60)
73 rdata := append([]byte{byte(len(val))}, val...)
74 resp = append(resp, byte(len(rdata)>>8), byte(len(rdata)))
75 resp = append(resp, rdata...)
76 }
77 pc.WriteTo(resp, addr)
78 }
79 }()
80 return pc.LocalAddr().String()
81}
82
83func TestPages(t *testing.T) {
84 var challenge atomic.Value
85 challenge.Store("")
86 t.Setenv("GITBAY_DNS_SERVER", fakeDNS(t, &challenge))
87 t.Setenv("GITBAY_DOMAIN_PENDING_TTL", "5s")
88 inst := startInstanceWith(t, "[pages]\ndomain = \"p.test\"\n")
89 aliceKey := inst.newKey(t, "alice")
90 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
91
92 env := inst.gitEnv(aliceKey)
93 pushPages := func(repo string, files map[string]string) {
94 t.Helper()
95 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", repo); code != 0 {
96 t.Fatalf("create %s: %s", repo, errOut)
97 }
98 work := t.TempDir()
99 mustGit(t, work, env, "clone", inst.sshURL(repo), "w")
100 dir := filepath.Join(work, "w")
101 for name, content := range files {
102 os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755)
103 os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
104 }
105 mustGit(t, dir, env, "checkout", "-q", "-b", "pages")
106 mustGit(t, dir, env, "add", ".")
107 mustGit(t, dir, env, "commit", "-q", "-m", "site")
108 mustGit(t, dir, env, "push", "-q", "origin", "pages")
109 }
110
111 pushPages("alice/pages", map[string]string{
112 "index.html": "<h1>alice root</h1><script>x=1</script>",
113 })
114 pushPages("alice/site", map[string]string{
115 "index.html": "<h1>project site</h1>",
116 "style.css": "body{color:red}",
117 "guide/index.html": "<h1>guide</h1>",
118 })
119
120 // Root site from the "pages" repo, scripts intact, no forge CSP.
121 resp, body := inst.pagesGet(t, "alice.p.test", "/")
122 if resp.StatusCode != 200 || !strings.Contains(body, "alice root") || !strings.Contains(body, "<script>") {
123 t.Fatalf("root site: %d\n%s", resp.StatusCode, body)
124 }
125 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
126 t.Fatalf("root content-type: %s", ct)
127 }
128 if resp.Header.Get("Content-Security-Policy") != "" {
129 t.Fatal("forge CSP leaked onto a pages response")
130 }
131
132 // Project site under /<repo>/, with a redirect adding the slash.
133 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site"); resp.StatusCode != 301 {
134 t.Fatalf("bare project path: %d", resp.StatusCode)
135 }
136 if resp, body = inst.pagesGet(t, "alice.p.test", "/site/"); !strings.Contains(body, "project site") {
137 t.Fatalf("project index: %d\n%s", resp.StatusCode, body)
138 }
139 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/style.css"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/css") {
140 t.Fatalf("css content-type: %s", resp.Header.Get("Content-Type"))
141 }
142 // Directory paths inside a site serve their index and gain a slash.
143 if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/guide"); resp.StatusCode != 301 {
144 t.Fatalf("dir redirect: %d", resp.StatusCode)
145 }
146 if _, body = inst.pagesGet(t, "alice.p.test", "/site/guide/"); !strings.Contains(body, "guide") {
147 t.Fatalf("dir index:\n%s", body)
148 }
149
150 // Private repos never serve pages; unknown owners and the apex 404.
151 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
152 t.Fatalf("create secret: %s", errOut)
153 }
154 work := t.TempDir()
155 mustGit(t, work, env, "clone", inst.sshURL("alice/secret"), "w")
156 sdir := filepath.Join(work, "w")
157 os.WriteFile(filepath.Join(sdir, "index.html"), []byte("hidden"), 0o644)
158 mustGit(t, sdir, env, "checkout", "-q", "-b", "pages")
159 mustGit(t, sdir, env, "add", ".")
160 mustGit(t, sdir, env, "commit", "-q", "-m", "s")
161 mustGit(t, sdir, env, "push", "-q", "origin", "pages")
162 for _, tc := range []struct{ host, path string }{
163 {"alice.p.test", "/secret/"},
164 {"bob.p.test", "/"},
165 } {
166 if resp, _ = inst.pagesGet(t, tc.host, tc.path); resp.StatusCode != 404 {
167 t.Fatalf("%s%s: %d, want 404", tc.host, tc.path, resp.StatusCode)
168 }
169 }
170 // The apex redirects to the forge.
171 if resp, _ = inst.pagesGet(t, "p.test", "/"); resp.StatusCode != 302 || !strings.Contains(resp.Header.Get("Location"), "gitbay.test") {
172 t.Fatalf("apex: %d -> %s", resp.StatusCode, resp.Header.Get("Location"))
173 }
174
175 // The forge itself still answers on its own host.
176 if status, _ := inst.get(t, "/explore"); status != 200 {
177 t.Fatalf("forge routes broken: %d", status)
178 }
179
180 // --- custom domains ---
181 bobKey := inst.newKey(t, "bob")
182 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
183
184 if _, _, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 4 {
185 t.Fatal("non-admin claimed a domain")
186 }
187 out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org", "--json")
188 if code != 0 {
189 t.Fatalf("domain add: %s", errOut)
190 }
191 var addEnv struct {
192 Data struct {
193 ChallengeValue string `json:"challenge_value"`
194 } `json:"data"`
195 }
196 if err := json.Unmarshal([]byte(out), &addEnv); err != nil || addEnv.Data.ChallengeValue == "" {
197 t.Fatalf("no challenge in add output: %s", out)
198 }
199 // Pending claims hold the name but serve nothing.
200 if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") {
201 t.Fatal("pending claim already serves")
202 }
203 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "create", "bob/held"); code != 0 {
204 t.Fatalf("bob repo: %s", errOut)
205 }
206 if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "docs.example.org"); code != 2 {
207 t.Fatal("pending claim did not hold the name")
208 }
209 // Verification: wrong record refused, right record activates.
210 challenge.Store("gitbay-domain-verify=nope")
211 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 4 {
212 t.Fatal("wrong TXT accepted")
213 }
214 challenge.Store(addEnv.Data.ChallengeValue)
215 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 0 {
216 t.Fatalf("verify: %s", errOut)
217 }
218 // The whole path maps into the repo's pages branch, no /<repo>/ prefix.
219 resp, body = inst.pagesGet(t, "docs.example.org", "/")
220 if resp.StatusCode != 200 || !strings.Contains(body, "project site") {
221 t.Fatalf("custom domain root: %d\n%s", resp.StatusCode, body)
222 }
223 if resp, _ = inst.pagesGet(t, "docs.example.org", "/style.css"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/css") {
224 t.Fatalf("custom domain css: %s", resp.Header.Get("Content-Type"))
225 }
226 if resp.Header.Get("Content-Security-Policy") != "" {
227 t.Fatal("forge CSP on a custom-domain response")
228 }
229 // Claims are exclusive, without naming the holder.
230 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/other"); code != 0 {
231 t.Fatalf("bob repo: %s", errOut)
232 }
233 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/other", "docs.example.org"); code != 2 || strings.Contains(errOut, "alice") {
234 t.Fatalf("duplicate claim: exit %d, %s", code, errOut)
235 }
236 // The forge host and bad domains are refused; private repos refused.
237 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "gitbay.test"); code != 2 {
238 t.Fatal("claimed the forge host")
239 }
240 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "sub.p.test"); code != 2 {
241 t.Fatal("claimed the built-in pages domain")
242 }
243 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/secret", "priv.example.org"); code != 2 {
244 t.Fatal("private repo got a domain")
245 }
246 // repo show lists it; removal stops serving.
247 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site")
248 if !regexp.MustCompile(`pages domains\s+docs\.example\.org`).MatchString(out) {
249 t.Fatalf("repo show missing domains:\n%s", out)
250 }
251 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "remove", "alice/site", "docs.example.org"); code != 0 {
252 t.Fatal("domain remove failed")
253 }
254 // An unmapped host falls through to the forge (default-vhost), so the
255 // site content specifically must be gone.
256 if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") {
257 t.Fatal("removed domain still serves")
258 }
259
260 // Expired pending claims free the name; live ones hold it.
261 if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "exp.example.org"); code != 0 {
262 t.Fatalf("expiry claim: %s", errOut)
263 }
264 if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 2 {
265 t.Fatal("live pending claim did not hold the name")
266 }
267 // One TTL (GITBAY_DOMAIN_PENDING_TTL=5s) plus real slack: timestamps
268 // have second granularity, so a 5.5s sleep can land on a diff of
269 // exactly 5, which is not > TTL.
270 time.Sleep(7 * time.Second)
271 if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 0 {
272 t.Fatalf("expired claim still held the name: %s", errOut)
273 }
274 // The original claimant's expired claim is gone, not resurrectable.
275 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "exp.example.org"); code != 3 {
276 t.Fatal("expired claim still verifiable")
277 }
278}