e2e/pages_test.go

b347d6c8c464e3c965455795f4e22e0aaeed0652
gitbay/e2e/pages_test.go history · blame · raw

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}