e2e/snippetweb_test.go

da232bfde4952959944a074ccec455f38eab3b66
gitbay/e2e/snippetweb_test.go history · blame · raw

205 lines · 9422 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	// Removing a file needs its name typed; a bare post is refused and
142	// the file stays.
143	_, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}})
144	if !strings.Contains(body, "type b.txt to confirm") {
145		t.Fatalf("unconfirmed file remove was not refused:\n%s", body)
146	}
147	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 0 {
148		t.Fatalf("b.txt removed without confirmation: exit %d", code)
149	}
150	if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}, "confirm": {"b.txt"}}); status != 200 {
151		t.Fatal("file remove failed")
152	}
153	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
154		t.Fatalf("b.txt after web remove: exit %d", code)
155	}
156	// A refusal comes back on the page as a message, not a bare error.
157	// The confirmation matches, so the refusal under test is still the
158	// command's last-file rule.
159	_, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}, "confirm": {"notes.md"}})
160	if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
161		t.Fatalf("last-file refusal on the page:\n%s", body)
162	}
163
164	// Edit changes visibility; delete removes.
165	if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
166		t.Fatal("edit failed")
167	}
168	if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
169		t.Fatalf("private after web edit, anonymous: %d", status)
170	}
171	// bob cannot write alice's snippet from the browser either.
172	bob := inst.login(t, bobKey)
173	// A logged-in stranger sees the same visibility rule as anonymous:
174	// 404 for a private snippet, 200 for an unlisted one.
175	if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+private); status != 404 {
176		t.Fatalf("stranger on a private page: %d", status)
177	}
178	if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
179		t.Fatalf("stranger on an unlisted page: %d", status)
180	}
181	// An unconfirmed delete on a private snippet is still 404 for a
182	// stranger: the snippet is resolved, and refused, before the
183	// confirmation is even checked.
184	if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+private+"/delete", nil); status != 404 {
185		t.Fatalf("stranger's unconfirmed delete on a private snippet: %d", status)
186	}
187	if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
188		t.Fatalf("bob editing alice's snippet: %d", status)
189	}
190	// Deleting needs the public id typed to confirm; a bare post leaves
191	// the snippet in place.
192	_, body = browserPost(t, alice, page+"/delete", nil)
193	if !strings.Contains(body, "type "+created+" to confirm") {
194		t.Fatalf("unconfirmed delete was not refused:\n%s", body)
195	}
196	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 0 {
197		t.Fatalf("snippet deleted without confirmation: exit %d", code)
198	}
199	if status, _ := browserPost(t, alice, page+"/delete", url.Values{"confirm": {created}}); status != 200 {
200		t.Fatal("delete failed")
201	}
202	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
203		t.Fatalf("after web delete: exit %d", code)
204	}
205}