e2e/snippetweb_test.go
179 lines · 8004 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8 "testing"
9)
10
11func snippetIDFrom(t *testing.T, out string) string {
12 t.Helper()
13 var env struct {
14 Data struct {
15 ID string `json:"id"`
16 } `json:"data"`
17 }
18 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
19 t.Fatalf("snippet create: %s", out)
20 }
21 return env.Data.ID
22}
23
24// Snippet pages: the owner's list, one snippet with highlighted files, the
25// raw route, the owner-page link, and 404 for what the viewer may not see.
26func TestSnippetsWeb(t *testing.T) {
27 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
28 aliceKey := inst.newKey(t, "alice")
29 bobKey := inst.newKey(t, "bob")
30 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
31 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
32 must := func(key, stdin string, args ...string) string {
33 t.Helper()
34 out, errOut, code := inst.ssh(t, key, stdin, args...)
35 if code != 0 {
36 t.Fatalf("%v: exit %d %s", args, code, errOut)
37 }
38 return out
39 }
40 public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
41 unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
42 private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
43
44 // Anonymous: the public list, the unlisted page by URL, 404 for private.
45 status, body := inst.get(t, "/alice/-/snippets")
46 if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
47 t.Fatalf("anonymous list: %d\n%s", status, body)
48 }
49 status, body = inst.get(t, "/alice/-/snippets/"+public)
50 if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
51 t.Fatalf("public page: %d\n%s", status, body)
52 }
53 if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
54 t.Fatalf("unlisted page: %d", status)
55 }
56 if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
57 t.Fatalf("private page for anonymous: %d", status)
58 }
59 if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
60 t.Fatalf("id under the wrong owner: %d", status)
61 }
62 if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
63 t.Fatalf("list for a missing owner: %d", status)
64 }
65
66 // Raw is text/plain with nosniff, whatever the extension.
67 resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
68 if err != nil {
69 t.Fatal(err)
70 }
71 resp.Body.Close()
72 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
73 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
74 }
75 if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
76 t.Fatalf("raw for a missing file: %d", status)
77 }
78
79 // The owner sees everything with visibility marks; the owner page links.
80 alice := inst.login(t, aliceKey)
81 status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
82 if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
83 t.Fatalf("owner list: %d\n%s", status, body)
84 }
85 if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
86 t.Fatalf("owner's private page: %d", status)
87 }
88 if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
89 t.Fatalf("owner page lacks the snippets link: %d", status)
90 }
91 // bob has no public snippets and is not the viewer: no link.
92 if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
93 t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
94 }
95
96 // The create form makes a snippet through snippet create.
97 status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
98 "name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
99 if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
100 t.Fatalf("create form: %d\n%s", status, body)
101 }
102 var listed struct {
103 Data []struct {
104 ID string `json:"id"`
105 Description string `json:"description"`
106 } `json:"data"`
107 }
108 json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
109 created := ""
110 for _, sn := range listed.Data {
111 if sn.Description == "from the browser" {
112 created = sn.ID
113 }
114 }
115 if created == "" {
116 t.Fatalf("created from the web, not listed: %+v", listed.Data)
117 }
118 if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
119 t.Fatalf("new form under another owner: %d", status)
120 }
121
122 // A refused create re-renders the form with the paste kept, not a
123 // bare error page.
124 _, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
125 "name": {"../x"}, "content": {"kept content\n"}})
126 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "kept content") {
127 t.Fatalf("refused create form:\n%s", body)
128 }
129
130 // The file form replaces a file and adds one; remove drops it.
131 page := inst.base() + "/alice/-/snippets/" + created
132 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
133 t.Fatal("file replace failed")
134 }
135 if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
136 t.Fatalf("after web replace: %q", got)
137 }
138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
139 t.Fatal("file add failed")
140 }
141 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
142 t.Fatal("file remove failed")
143 }
144 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
145 t.Fatalf("b.txt after web remove: exit %d", code)
146 }
147 // A refusal comes back on the page as a message, not a bare error.
148 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
149 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
150 t.Fatalf("last-file refusal on the page:\n%s", body)
151 }
152
153 // Edit changes visibility; delete removes.
154 if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
155 t.Fatal("edit failed")
156 }
157 if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
158 t.Fatalf("private after web edit, anonymous: %d", status)
159 }
160 // bob cannot write alice's snippet from the browser either.
161 bob := inst.login(t, bobKey)
162 // A logged-in stranger sees the same visibility rule as anonymous:
163 // 404 for a private snippet, 200 for an unlisted one.
164 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+private); status != 404 {
165 t.Fatalf("stranger on a private page: %d", status)
166 }
167 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
168 t.Fatalf("stranger on an unlisted page: %d", status)
169 }
170 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
171 t.Fatalf("bob editing alice's snippet: %d", status)
172 }
173 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
174 t.Fatal("delete failed")
175 }
176 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
177 t.Fatalf("after web delete: exit %d", code)
178 }
179}