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