e2e/wiki_test.go
229 lines · 10075 bytes
1 symbol in this file
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 \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\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}