e2e/wiki_test.go

3bcdce33fb9a2309312854331359d376171c7368
gitbay/e2e/wiki_test.go history · blame · raw

229 lines · 10075 bytes

  1package e2e
  2
  3import (
  4	"net/http"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9)
 10
 11func TestWikis(t *testing.T) {
 12	t.Parallel()
 13	inst := startInstance(t)
 14	aliceKey := inst.newKey(t, "alice")
 15	bobKey := inst.newKey(t, "bob")
 16	inst.admin(t, "admin", "user", "create", "alice",
 17		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 18	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 19	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 20		t.Fatal("repo create failed")
 21	}
 22	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secretive", "--private"); code != 0 {
 23		t.Fatal("private repo create failed")
 24	}
 25
 26	// No wiki yet: the tab is absent, the page shows the missing hint, and
 27	// listing reports no wiki rather than erroring.
 28	_, body := inst.get(t, "/alice/app")
 29	if strings.Contains(body, ">Wiki<") {
 30		t.Fatal("wiki tab shown with no wiki")
 31	}
 32	_, body = inst.get(t, "/alice/app/wiki")
 33	if !strings.Contains(body, "no wiki yet") {
 34		t.Fatal("missing-wiki hint absent")
 35	}
 36	if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json"); code != 0 {
 37		t.Fatalf("wiki list on a repo without one: %s", errOut)
 38	} else if !strings.Contains(out, `"pages":[]`) {
 39		t.Errorf("wiki list on a repo without one returned pages: %s", out)
 40	}
 41
 42	// Pages are ordinary files: pushing them creates the wiki.
 43	env := inst.gitEnv(aliceKey)
 44	work := t.TempDir()
 45	mustGit(t, work, env, "init", "-q", "-b", "main", "w")
 46	dir := filepath.Join(work, "w")
 47	os.MkdirAll(filepath.Join(dir, ".gitbay", "wiki"), 0o755)
 48	os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Home.md"), []byte(
 49		"# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644)
 50	os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644)
 51	os.WriteFile(filepath.Join(dir, ".gitbay", "wiki", "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
 52	os.WriteFile(filepath.Join(dir, "top.txt"), []byte("not part of the wiki\n"), 0o644)
 53	mustGit(t, dir, env, "add", ".")
 54	mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
 55	mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
 56
 57	benv := inst.gitEnv(bobKey)
 58	if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app"), "main"); code == 0 && !strings.Contains(out, "denied") {
 59		t.Fatalf("reader pushed the repository: %d\n%s", code, out)
 60	}
 61
 62	// Rendering: home resolves, tab appears, links rewrite to wiki pages
 63	// and images to the wiki raw route; org pages render too.
 64	_, body = inst.get(t, "/alice/app")
 65	if !strings.Contains(body, ">Wiki<") {
 66		t.Fatal("wiki tab missing after push")
 67	}
 68	_, body = inst.get(t, "/alice/app/wiki")
 69	if !strings.Contains(body, "welcome") ||
 70		!strings.Contains(body, `href="/alice/app/wiki/Setup"`) ||
 71		!strings.Contains(body, `src="/alice/app/wiki/_raw/shot.png"`) {
 72		t.Fatalf("wiki home rendering:\n%s", body)
 73	}
 74	_, body = inst.get(t, "/alice/app/wiki/Setup")
 75	if !strings.Contains(body, "steps here") {
 76		t.Fatal("org wiki page missing")
 77	}
 78	if status, _ := inst.get(t, "/alice/app/wiki/Nope"); status != 404 {
 79		t.Fatalf("missing page: %d", status)
 80	}
 81	// The raw route serves the image bytes.
 82	status, raw := inst.get(t, "/alice/app/wiki/_raw/shot.png")
 83	if status != 200 || !strings.HasPrefix(raw, "\x89PNG") {
 84		t.Fatalf("wiki raw: %d", status)
 85	}
 86	// The raw route cannot climb out of .gitbay/wiki. A literal ".." is
 87	// caught by the mux's own path cleaning, which would make this pass
 88	// vacuously; percent-encoding it reaches the handler with real ".."
 89	// segments in PathValue, which is what the guard has to refuse.
 90	if status, body := inst.get(t, "/alice/app/wiki/_raw/%2e%2e/%2e%2e/top.txt"); status == 200 {
 91		t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body)
 92	}
 93
 94	// A page in a subfolder is named by its path. Its links resolve from
 95	// its own folder first and then the wiki root, the sidebar groups it
 96	// under the folder, and an SVG next to it is served as an image.
 97	guide := filepath.Join(dir, ".gitbay", "wiki", "Guide")
 98	os.MkdirAll(guide, 0o755)
 99	os.WriteFile(filepath.Join(guide, "Install.md"), []byte(
100		"# install steps\n\n[Next](Next.md), [Setup](Setup.org)\n\n![flow](flow.svg)\n"), 0o644)
101	os.WriteFile(filepath.Join(guide, "Next.md"), []byte("# next step\n"), 0o644)
102	os.WriteFile(filepath.Join(guide, "flow.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644)
103	mustGit(t, dir, env, "add", ".")
104	mustGit(t, dir, env, "commit", "-q", "-m", "wiki subfolder")
105	mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
106	status, body = inst.get(t, "/alice/app/wiki/Guide/Install")
107	if status != 200 || !strings.Contains(body, "install steps") {
108		t.Fatalf("subfolder page: %d\n%s", status, body)
109	}
110	for _, want := range []string{
111		`href="/alice/app/wiki/Guide/Next"`,
112		`href="/alice/app/wiki/Setup"`,
113		`src="/alice/app/wiki/_raw/Guide/flow.svg"`,
114		`<p class="meta wikidir">Guide</p>`,
115		`href="/alice/app/wiki/Guide/Install">Install</a>`,
116	} {
117		if !strings.Contains(body, want) {
118			t.Errorf("subfolder page lacks %s", want)
119		}
120	}
121	resp, err := http.Get(inst.base() + "/alice/app/wiki/_raw/Guide/flow.svg")
122	if err != nil {
123		t.Fatal(err)
124	}
125	resp.Body.Close()
126	if ct := resp.Header.Get("Content-Type"); resp.StatusCode != 200 || ct != "image/svg+xml" {
127		t.Errorf("wiki svg: %d %q", resp.StatusCode, ct)
128	}
129	out, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Guide/Next", "--json")
130	if code != 0 || !strings.Contains(out, "next step") {
131		t.Errorf("wiki show Guide/Next: %s", out)
132	}
133
134	// A wiki is readable from every surface, not just a browser: the
135	// commands are what the web dispatches, and what the CLI and the
136	// JSON API reach.
137	out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/app", "--json")
138	if code != 0 {
139		t.Fatalf("wiki list: %s", errOut)
140	}
141	if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) {
142		t.Errorf("wiki list pages: %s", out)
143	}
144	if !strings.Contains(out, `"Guide/Install"`) || !strings.Contains(out, `"Guide/Next"`) {
145		t.Errorf("wiki list lacks subfolder pages: %s", out)
146	}
147	if !strings.Contains(out, `"home":"Home"`) {
148		t.Errorf("wiki list did not name the landing page: %s", out)
149	}
150	// shot.png is not a page.
151	if strings.Contains(out, "shot") {
152		t.Errorf("wiki list included a non-page file: %s", out)
153	}
154
155	// Named page, and the landing page when none is named.
156	out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup", "--json")
157	if code != 0 || !strings.Contains(out, "steps here") {
158		t.Errorf("wiki show Setup: %s", out)
159	}
160	out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "--json")
161	if code != 0 || !strings.Contains(out, "welcome") {
162		t.Errorf("wiki show default page: %s", out)
163	}
164	// An extension is accepted and ignored, as the web's routes do.
165	if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Setup.org"); code != 0 {
166		t.Error("wiki show rejected a page named with its extension")
167	}
168	if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Nope"); code == 0 {
169		t.Error("a missing wiki page resolved")
170	}
171	// A page name cannot climb out of the wiki.
172	if _, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "../../etc/passwd"); code == 0 {
173		t.Error("wiki show escaped the repository")
174	}
175	// A repository with no wiki says so rather than failing oddly, even
176	// for its owner.
177	if out, errOut, code := inst.ssh(t, aliceKey, "", "wiki", "list", "alice/secretive", "--json"); code != 0 {
178		t.Fatalf("wiki list on a repo without one: %s", errOut)
179	} else if !strings.Contains(out, `"pages":[]`) {
180		t.Errorf("wiki list on a repo without one returned pages: %s", out)
181	}
182	// Wiki access derives from the parent: a stranger gets nothing.
183	if _, _, code := inst.ssh(t, bobKey, "", "wiki", "list", "alice/secretive"); code == 0 {
184		t.Error("a stranger listed a private repository's wiki")
185	}
186
187	// 404-parity: a private repo's wiki is invisible, over web and git.
188	if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
189		t.Fatalf("private wiki page: %d", status)
190	}
191
192	// Pushing to <name>.wiki.git is refused now that the companion route
193	// is gone; there is no such repository.
194	if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "x"); code == 0 {
195		t.Fatalf("cloned a nonexistent companion: %s", out)
196	} else if !strings.Contains(out, "not found") {
197		t.Fatalf("clone of alice/app.wiki: %s", out)
198	}
199
200	// A repository may now be named something.wiki: the suffix is no
201	// longer reserved.
202	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 0 {
203		t.Fatalf("something.wiki repo name refused: %s", errOut)
204	}
205
206	// repo commit-file writes a page on a repository that permits
207	// server-authored commits: it is the command behind the web editor,
208	// and there is no wiki-specific write command.
209	if _, errOut, code := inst.ssh(t, aliceKey, "written by commit-file\n",
210		"repo", "commit-file", "alice/app", ".gitbay/wiki/Extra.md",
211		"--ref", "main", "--message", "'add a page'", "--file", "-"); code != 0 {
212		t.Fatalf("repo commit-file: %s", errOut)
213	}
214	out, _, code = inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Extra", "--json")
215	if code != 0 || !strings.Contains(out, "written by commit-file") {
216		t.Errorf("wiki show Extra: %s", out)
217	}
218
219	// A repository requiring verified signatures refuses repo commit-file,
220	// since the server cannot sign on the user's behalf.
221	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/app", "on"); code != 0 {
222		t.Fatal("require-signed failed")
223	}
224	if _, errOut, code := inst.ssh(t, aliceKey, "blocked\n",
225		"repo", "commit-file", "alice/app", ".gitbay/wiki/Blocked.md",
226		"--ref", "main", "--file", "-"); code == 0 || !strings.Contains(errOut, "requires signed commits") {
227		t.Errorf("repo commit-file not refused on a signed-commits repo: %d %s", code, errOut)
228	}
229}