e2e/pages_test.go

v1.43.1
gitbay/e2e/pages_test.go history · blame · raw

278 lines · 10834 bytes

  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}