carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1package todotest
2
3import (
4 "net/http"
5 "net/url"
6 "os"
7 "slices"
8 "strconv"
9 "strings"
10 "testing"
11)
12
13func TestFirstVisitSetsListCookie(t *testing.T) {
14 t.Parallel()
15 b := newBrowser(t, newApp(t))
16 rec := b.get("/")
17 if rec.Code != http.StatusOK {
18 t.Fatalf("GET /: status %d", rec.Code)
19 }
20 cs := rec.Result().Cookies()
21 if len(cs) != 1 {
22 t.Fatalf("got %d cookies, want 1", len(cs))
23 }
24 c := cs[0]
25 if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
26 t.Errorf("cookie %+v: want HttpOnly, SameSite=Lax, Path=/", c)
27 }
28 if len(c.Value) < 22 { // 128 bits in base64
29 t.Errorf("cookie value %q is too short to hold 128 random bits", c.Value)
30 }
31 // A second visit keeps the same list: no new cookie.
32 if got := b.get("/").Result().Cookies(); len(got) != 0 {
33 t.Errorf("second visit set cookies %v", got)
34 }
35}
36
37func TestEmptyListHidesMainAndFooter(t *testing.T) {
38 t.Parallel()
39 body := newBrowser(t, newApp(t)).get("/").Body.String()
40 for _, s := range []string{`class="main"`, `class="footer"`} {
41 if strings.Contains(body, s) {
42 t.Errorf("empty list renders %s", s)
43 }
44 }
45 if !strings.Contains(body, `class="new-todo"`) {
46 t.Error("page has no new-todo field")
47 }
48 if !strings.Contains(body, "the CARLOS bakeoff</a> · Go + Rastrillo") {
49 t.Error("footer doesn't name the setup")
50 }
51}
52
53func TestAddTrimsAndRedirectsToFilter(t *testing.T) {
54 t.Parallel()
55 b := newBrowser(t, newApp(t))
56 for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
57 rec := b.post("/todos", url.Values{"title": {" x "}, "filter": {filter}})
58 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want {
59 t.Errorf("filter %q: %d to %q, want 303 to %q", filter, rec.Code, rec.Header().Get("Location"), want)
60 }
61 }
62 b.add(" ")
63 got := titles(b.get("/").Body.String())
64 if want := []string{"x", "x", "x", "x", "x"}; !slices.Equal(got, want) {
65 t.Errorf("titles %q, want %q", got, want)
66 }
67}
68
69func TestTitlesAreTextAndKeptExactly(t *testing.T) {
70 t.Parallel()
71 b := newBrowser(t, newApp(t))
72 b.add(`<img src=x onerror=alert(1)> & "q"`)
73 b.add("naïve 🚀 日本語")
74 long := strings.Repeat("x", 10_000)
75 b.add(long)
76 rec := b.get("/")
77 got := titles(rec.Body.String())
78 want := []string{"&lt;img src=x onerror=alert(1)&gt; &amp; &#34;q&#34;", "naïve 🚀 日本語", long}
79 if !slices.Equal(got, want) {
80 t.Errorf("titles %q, want %q", got, want)
81 }
82 if n, _ := strconv.Atoi(rec.Header().Get("Content-Length")); n != rec.Body.Len() {
83 t.Errorf("Content-Length %d, body %d bytes", n, rec.Body.Len())
84 }
85}
86
87func TestToggleEditDestroy(t *testing.T) {
88 t.Parallel()
89 b := newBrowser(t, newApp(t))
90 b.add("one")
91 b.add("two")
92 id := items(b.get("/").Body.String())[0].ID
93
94 rec := b.post("/todos/"+id+"/toggle", url.Values{"filter": {"active"}})
95 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/active" {
96 t.Fatalf("toggle: %d to %q", rec.Code, rec.Header().Get("Location"))
97 }
98 if got := titles(b.get("/active").Body.String()); !slices.Equal(got, []string{"two"}) {
99 t.Errorf("active after toggle: %q", got)
100 }
101 if got := titles(b.get("/completed").Body.String()); !slices.Equal(got, []string{"one"}) {
102 t.Errorf("completed after toggle: %q", got)
103 }
104 if !strings.Contains(b.get("/").Body.String(), "<strong>1</strong> item left") {
105 t.Error("counter doesn't read 1 item left")
106 }
107
108 b.post("/todos/"+id+"/edit", url.Values{"title": {" uno "}, "filter": {"all"}})
109 if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"uno", "two"}) {
110 t.Errorf("after edit: %q", got)
111 }
112 b.post("/todos/"+id+"/edit", url.Values{"title": {" "}, "filter": {"all"}})
113 if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"two"}) {
114 t.Errorf("empty edit should delete: %q", got)
115 }
116
117 id2 := items(b.get("/").Body.String())[0].ID
118 if rec := b.post("/todos/"+id2+"/destroy", url.Values{"filter": {"all"}}); rec.Code != http.StatusSeeOther {
119 t.Fatalf("destroy: %d", rec.Code)
120 }
121 if got := titles(b.get("/").Body.String()); len(got) != 0 {
122 t.Errorf("after destroy: %q", got)
123 }
124}
125
126func TestEditView(t *testing.T) {
127 t.Parallel()
128 b := newBrowser(t, newApp(t))
129 b.add("one")
130 id := items(b.get("/").Body.String())[0].ID
131 body := b.get("/active?edit=" + id).Body.String()
132 if it := items(body)[0]; it.Class != "editing" {
133 t.Errorf("li class %q, want editing", it.Class)
134 }
135 if !strings.Contains(body, `<form method="post" action="/todos/`+id+`/edit">`) {
136 t.Error("edit input isn't in a form posting to /todos/<id>/edit")
137 }
138 if !strings.Contains(body, `href="/active?edit=`+id+`"`) {
139 t.Error("edit link doesn't keep the filter")
140 }
141}
142
143func TestToggleAllAndClearCompleted(t *testing.T) {
144 t.Parallel()
145 b := newBrowser(t, newApp(t))
146 b.add("a")
147 b.add("b")
148 id := items(b.get("/").Body.String())[0].ID
149 b.post("/todos/"+id+"/toggle", url.Values{})
150
151 b.post("/todos/toggle-all", url.Values{"filter": {"all"}})
152 body := b.get("/").Body.String()
153 for _, it := range items(body) {
154 if it.Class != "completed" {
155 t.Errorf("after toggle-all, %s has class %q", it.Title, it.Class)
156 }
157 }
158 if !strings.Contains(body, `class="toggle-all" type="checkbox" checked`) {
159 t.Error("toggle-all isn't checked when everything is completed")
160 }
161 if !strings.Contains(body, `class="clear-completed"`) {
162 t.Error("clear-completed hidden with completed items")
163 }
164
165 b.post("/todos/toggle-all", url.Values{"filter": {"all"}})
166 for _, it := range items(b.get("/").Body.String()) {
167 if it.Class != "" {
168 t.Errorf("after second toggle-all, %s has class %q", it.Title, it.Class)
169 }
170 }
171 if strings.Contains(b.get("/").Body.String(), `class="clear-completed"`) {
172 t.Error("clear-completed shown with nothing completed")
173 }
174
175 b.post("/todos/"+id+"/toggle", url.Values{})
176 b.post("/todos/clear-completed", url.Values{"filter": {"completed"}})
177 if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"b"}) {
178 t.Errorf("after clear-completed: %q", got)
179 }
180}
181
182func TestListsAreSeparate(t *testing.T) {
183 t.Parallel()
184 h := newApp(t)
185 alice, mallory := newBrowser(t, h), newBrowser(t, h)
186 alice.add("mine")
187 id := items(alice.get("/").Body.String())[0].ID
188
189 if got := titles(mallory.get("/").Body.String()); len(got) != 0 {
190 t.Fatalf("another visitor sees %q", got)
191 }
192 for _, action := range []string{"toggle", "edit", "destroy"} {
193 rec := mallory.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}, "filter": {"all"}})
194 if rec.Code != http.StatusNotFound {
195 t.Errorf("%s on another list's item: %d, want 404", action, rec.Code)
196 }
197 }
198 for _, path := range []string{"/todos/toggle-all", "/todos/clear-completed"} {
199 mallory.post(path, url.Values{})
200 }
201 if got := items(alice.get("/").Body.String()); len(got) != 1 || got[0].Title != "mine" || got[0].Class != "" {
202 t.Errorf("alice's list changed: %+v", got)
203 }
204 for _, id := range []string{"999999", "abc", "-1"} {
205 if rec := alice.post("/todos/"+id+"/toggle", url.Values{}); rec.Code != http.StatusNotFound {
206 t.Errorf("toggle %s: %d, want 404", id, rec.Code)
207 }
208 }
209}
210
211func TestUnknownCookieGetsFreshList(t *testing.T) {
212 t.Parallel()
213 h := newApp(t)
214 alice := newBrowser(t, h)
215 alice.add("mine")
216 var name string
217 for n := range alice.cookies {
218 name = n
219 }
220 for _, v := range []string{"%%%", "garbage", strings.Repeat("A", 43)} {
221 b := newBrowser(t, h)
222 b.cookies[name] = &http.Cookie{Name: name, Value: v}
223 rec := b.get("/")
224 if rec.Code != http.StatusOK || len(items(rec.Body.String())) != 0 {
225 t.Errorf("cookie %q: status %d, items %v", v, rec.Code, items(rec.Body.String()))
226 }
227 }
228}
229
230func TestOriginCheck(t *testing.T) {
231 t.Parallel()
232 b := newBrowser(t, newApp(t))
233 form := url.Values{"title": {"x"}, "filter": {"all"}}
234 if rec := b.post("/todos", form, "Origin", "https://evil.example"); rec.Code != http.StatusForbidden {
235 t.Errorf("cross-site Origin: %d, want 403", rec.Code)
236 }
237 if rec := b.post("/todos", form, "Origin", "null"); rec.Code != http.StatusForbidden {
238 t.Errorf("Origin null: %d, want 403", rec.Code)
239 }
240 if got := titles(b.get("/").Body.String()); len(got) != 0 {
241 t.Fatalf("refused posts added %q", got)
242 }
243 // Sec-Fetch-Site can't vouch for a foreign Origin.
244 for _, site := range []string{"same-origin", "none"} {
245 if rec := b.post("/todos", form, "Origin", "https://evil.example", "Sec-Fetch-Site", site); rec.Code != http.StatusForbidden {
246 t.Errorf("foreign Origin with Sec-Fetch-Site %s: %d, want 403", site, rec.Code)
247 }
248 }
249 if got := titles(b.get("/").Body.String()); len(got) != 0 {
250 t.Fatalf("refused posts added %q", got)
251 }
252 if rec := b.post("/todos", form, "Origin", testOrigin); rec.Code != http.StatusSeeOther {
253 t.Errorf("own Origin: %d, want 303", rec.Code)
254 }
255 if rec := b.post("/todos", form); rec.Code != http.StatusSeeOther {
256 t.Errorf("no Origin: %d, want 303", rec.Code)
257 }
258}
259
260func TestFragmentForScript(t *testing.T) {
261 t.Parallel()
262 b := newBrowser(t, newApp(t))
263 rec := b.post("/todos", url.Values{"title": {"x"}, "filter": {"active"}}, "X-Todo-Fragment", "1")
264 if rec.Code != http.StatusOK {
265 t.Fatalf("fragment post: %d, want 200", rec.Code)
266 }
267 body := rec.Body.String()
268 if strings.Contains(body, "<html") || !strings.Contains(body, `class="todo-list"`) {
269 t.Errorf("fragment isn't the list markup alone:\n%s", body)
270 }
271 if !strings.Contains(body, `<a class="selected" href="/active">`) {
272 t.Error("fragment lost the filter")
273 }
274}
275
276func TestStylesheetsServedByteForByte(t *testing.T) {
277 t.Parallel()
278 h := newApp(t)
279 for _, name := range []string{"base.css", "index.css"} {
280 want, err := os.ReadFile("../../../../shared/" + name)
281 if err != nil {
282 t.Fatal(err)
283 }
284 rec := newBrowser(t, h).get("/static/" + name)
285 if rec.Code != http.StatusOK || rec.Body.String() != string(want) {
286 t.Errorf("/static/%s: status %d, %d bytes, want shared/%s (%d bytes)", name, rec.Code, rec.Body.Len(), name, len(want))
287 }
288 }
289}
290
291func TestOversizedEditChangesNothing(t *testing.T) {
292 t.Parallel()
293 b := newBrowser(t, newApp(t))
294 b.add("keep me")
295 id := items(b.get("/").Body.String())[0].ID
296 huge := strings.Repeat("x", 2<<20)
297 rec := b.post("/todos/"+id+"/edit", url.Values{"title": {huge}, "filter": {"all"}})
298 if rec.Code != http.StatusRequestEntityTooLarge {
299 t.Errorf("oversized edit: %d, want 413", rec.Code)
300 }
301 if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"keep me"}) {
302 t.Errorf("after oversized edit: %q", got)
303 }
304 if rec := b.post("/todos", url.Values{"title": {huge}}); rec.Code != http.StatusRequestEntityTooLarge {
305 t.Errorf("oversized add: %d, want 413", rec.Code)
306 }
307}
308