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 web
2
3import (
4 "bytes"
5 "context"
6 "net/http"
7 "net/http/cookiejar"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strconv"
14 "strings"
15 "testing"
16
17 "todo-go/internal/todo"
18)
19
20// client is one visitor: a cookie jar that doesn't follow redirects.
21type client struct {
22 t *testing.T
23 srv *httptest.Server
24 hc *http.Client
25}
26
27func newServer(t *testing.T) *httptest.Server {
28 t.Helper()
29 store, err := todo.Open(context.Background(), filepath.Join(t.TempDir(), "todo.db"))
30 if err != nil {
31 t.Fatal(err)
32 }
33 t.Cleanup(func() { store.Close() })
34 srv := httptest.NewUnstartedServer(nil)
35 srv.Config.Handler = New(store, Config{Origin: "http://" + srv.Listener.Addr().String(), Version: "test"})
36 srv.Start()
37 t.Cleanup(srv.Close)
38 return srv
39}
40
41func visitor(t *testing.T, srv *httptest.Server) *client {
42 jar, _ := cookiejar.New(nil)
43 hc := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
44 return http.ErrUseLastResponse
45 }}
46 return &client{t: t, srv: srv, hc: hc}
47}
48
49func (c *client) get(path string) (*http.Response, string) {
50 c.t.Helper()
51 res, err := c.hc.Get(c.srv.URL + path)
52 if err != nil {
53 c.t.Fatal(err)
54 }
55 defer res.Body.Close()
56 var b bytes.Buffer
57 b.ReadFrom(res.Body)
58 return res, b.String()
59}
60
61func (c *client) post(path string, form url.Values, header map[string]string) (*http.Response, string) {
62 c.t.Helper()
63 req, _ := http.NewRequest("POST", c.srv.URL+path, strings.NewReader(form.Encode()))
64 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
65 for k, v := range header {
66 req.Header.Set(k, v)
67 }
68 res, err := c.hc.Do(req)
69 if err != nil {
70 c.t.Fatal(err)
71 }
72 defer res.Body.Close()
73 var b bytes.Buffer
74 b.ReadFrom(res.Body)
75 return res, b.String()
76}
77
78var idRE = regexp.MustCompile(`data-id="(\d+)"`)
79
80func ids(body string) []string {
81 var out []string
82 for _, m := range idRE.FindAllStringSubmatch(body, -1) {
83 out = append(out, m[1])
84 }
85 return out
86}
87
88func TestActionsRedirectToTheirFilter(t *testing.T) {
89 c := visitor(t, newServer(t))
90 for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
91 res, _ := c.post("/todos", url.Values{"title": {"x"}, "filter": {filter}}, nil)
92 if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want {
93 t.Errorf("filter %q: %d to %q, want 303 to %q", filter, res.StatusCode, res.Header.Get("Location"), want)
94 }
95 }
96}
97
98func TestPageShowsFilterAndCounts(t *testing.T) {
99 c := visitor(t, newServer(t))
100 _, body := c.get("/")
101 if strings.Contains(body, `class="main"`) || strings.Contains(body, `class="footer"`) {
102 t.Fatal("empty list should hide .main and .footer")
103 }
104 c.post("/todos", url.Values{"title": {"a"}}, nil)
105 c.post("/todos", url.Values{"title": {"b"}}, nil)
106 _, body = c.get("/")
107 id := ids(body)[0]
108 c.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}}, nil)
109
110 _, body = c.get("/active")
111 if got := ids(body); len(got) != 1 {
112 t.Fatalf("/active shows %v", got)
113 }
114 for _, want := range []string{
115 `<strong>1</strong> item left`,
116 `<a class="selected" href="/active">Active</a>`,
117 `class="clear-completed"`,
118 `href="/active?edit=`,
119 } {
120 if !strings.Contains(body, want) {
121 t.Errorf("/active is missing %q", want)
122 }
123 }
124 _, body = c.get("/completed?edit=" + id)
125 if !strings.Contains(body, `class="completed editing" data-id="`+id+`"`) ||
126 !strings.Contains(body, `action="/todos/`+id+`/edit"`) {
127 t.Errorf("edit view not rendered:\n%s", body)
128 }
129}
130
131func TestEscapesTitles(t *testing.T) {
132 c := visitor(t, newServer(t))
133 c.post("/todos", url.Values{"title": {`<img src=x onerror=alert(1)> & "q"`}}, nil)
134 _, body := c.get("/")
135 if strings.Contains(body, "<img") {
136 t.Fatal("title rendered as HTML")
137 }
138 if !strings.Contains(body, "&lt;img src=x onerror=alert(1)&gt; &amp; &#34;q&#34;") {
139 t.Fatal("escaped title not found")
140 }
141}
142
143func TestContentLengthIsBytes(t *testing.T) {
144 c := visitor(t, newServer(t))
145 c.post("/todos", url.Values{"title": {"naïve 🚀 日本語"}}, nil)
146 res, body := c.get("/")
147 if n, _ := strconv.Atoi(res.Header.Get("Content-Length")); n != len(body) {
148 t.Fatalf("Content-Length %d, body %d bytes", n, len(body))
149 }
150}
151
152func TestCrossOriginPostIsRefused(t *testing.T) {
153 srv := newServer(t)
154 c := visitor(t, srv)
155 res, _ := c.post("/todos", url.Values{"title": {"evil"}}, map[string]string{"Origin": "https://evil.example"})
156 if res.StatusCode != http.StatusForbidden {
157 t.Fatalf("got %d, want 403", res.StatusCode)
158 }
159 res, _ = c.post("/todos", url.Values{"title": {"mine"}}, map[string]string{"Origin": srv.URL})
160 if res.StatusCode != http.StatusSeeOther {
161 t.Fatalf("own origin: got %d, want 303", res.StatusCode)
162 }
163 if _, body := c.get("/"); len(ids(body)) != 1 {
164 t.Fatal("want exactly the one same-origin todo")
165 }
166}
167
168func TestOtherListsIdsAre404(t *testing.T) {
169 srv := newServer(t)
170 alice, bob := visitor(t, srv), visitor(t, srv)
171 alice.post("/todos", url.Values{"title": {"alice's"}}, nil)
172 _, body := alice.get("/")
173 id := ids(body)[0]
174 bob.get("/")
175 for _, action := range []string{"toggle", "edit", "destroy"} {
176 res, _ := bob.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}}, nil)
177 if res.StatusCode != http.StatusNotFound {
178 t.Errorf("%s: got %d, want 404", action, res.StatusCode)
179 }
180 }
181 for _, bad := range []string{"abc", "99999", "-1"} {
182 if res, _ := alice.post("/todos/"+bad+"/toggle", nil, nil); res.StatusCode != http.StatusNotFound {
183 t.Errorf("id %q: got %d, want 404", bad, res.StatusCode)
184 }
185 }
186 if _, body := alice.get("/"); !strings.Contains(body, "alice&#39;s") || strings.Contains(body, `class="completed"`) {
187 t.Fatal("alice's list changed")
188 }
189}
190
191func TestGarbledCookieGetsFreshList(t *testing.T) {
192 srv := newServer(t)
193 for _, value := range []string{"%%%", "short", strings.Repeat("A", 43)} {
194 req, _ := http.NewRequest("GET", srv.URL+"/", nil)
195 req.Header.Set("Cookie", cookieName+"="+value)
196 res, err := http.DefaultClient.Do(req)
197 if err != nil {
198 t.Fatal(err)
199 }
200 res.Body.Close()
201 if res.StatusCode != http.StatusOK {
202 t.Errorf("cookie %q: status %d", value, res.StatusCode)
203 }
204 }
205}
206
207func TestCookieAttributes(t *testing.T) {
208 c := visitor(t, newServer(t))
209 res, _ := c.get("/")
210 sc := res.Header.Get("Set-Cookie")
211 for _, want := range []string{"HttpOnly", "SameSite=Lax", "Path=/"} {
212 if !strings.Contains(sc, want) {
213 t.Errorf("Set-Cookie %q lacks %s", sc, want)
214 }
215 }
216 if strings.Contains(sc, "Secure") {
217 t.Error("cookie is Secure over plain http")
218 }
219}
220
221func TestFragmentForScripts(t *testing.T) {
222 c := visitor(t, newServer(t))
223 res, body := c.post("/todos", url.Values{"title": {"a"}, "filter": {"all"}}, map[string]string{fragmentHeader: "1"})
224 if res.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(body), `<section class="main">`) ||
225 strings.Contains(body, "<html") {
226 t.Fatalf("got %d:\n%s", res.StatusCode, body)
227 }
228}
229
230// The contract wants TodoMVC's stylesheets byte for byte.
231func TestStaticCSSMatchesShared(t *testing.T) {
232 c := visitor(t, newServer(t))
233 for _, name := range []string{"base.css", "index.css"} {
234 want, err := os.ReadFile(filepath.Join("..", "..", "..", "shared", name))
235 if err != nil {
236 t.Skipf("shared/ not available: %v", err)
237 }
238 res, got := c.get("/static/" + name)
239 if res.StatusCode != 200 || got != string(want) {
240 t.Errorf("%s differs from shared/%s", name, name)
241 }
242 if !strings.HasPrefix(res.Header.Get("Content-Type"), "text/css") {
243 t.Errorf("%s served as %q", name, res.Header.Get("Content-Type"))
244 }
245 }
246}
247
248func TestHealthAndVersion(t *testing.T) {
249 c := visitor(t, newServer(t))
250 if res, _ := c.get("/healthz"); res.StatusCode != 200 {
251 t.Errorf("/healthz: %d", res.StatusCode)
252 }
253 if res, body := c.get("/api/version"); res.StatusCode != 200 || body != "test" {
254 t.Errorf("/api/version: %d %q", res.StatusCode, body)
255 }
256}
257
258// AGENTS.md caps the page's JavaScript at 300 lines.
259func TestScriptStaysSmall(t *testing.T) {
260 src, err := staticFS.ReadFile("static/app.js")
261 if err != nil {
262 t.Fatal(err)
263 }
264 if n := bytes.Count(src, []byte("\n")); n > 300 {
265 t.Fatalf("app.js is %d lines; the cap is 300", n)
266 }
267}
268
269// A well-formed cookie the server never issued is replaced, so two
270// visitors can't share a list by choosing the same value.
271func TestUnissuedCookieIsReplaced(t *testing.T) {
272 srv := newServer(t)
273 forged := strings.Repeat("A", 43) // decodes to 32 bytes
274 post := func() *http.Response {
275 req, _ := http.NewRequest("POST", srv.URL+"/todos", strings.NewReader("title=shared"))
276 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
277 req.Header.Set("Cookie", cookieName+"="+forged)
278 res, err := http.DefaultTransport.RoundTrip(req)
279 if err != nil {
280 t.Fatal(err)
281 }
282 res.Body.Close()
283 return res
284 }
285 for range 2 {
286 res := post()
287 var got string
288 for _, c := range res.Cookies() {
289 if c.Name == cookieName {
290 got = c.Value
291 }
292 }
293 if got == "" || got == forged {
294 t.Fatalf("forged cookie kept (Set-Cookie %q)", res.Header.Get("Set-Cookie"))
295 }
296 }
297 req, _ := http.NewRequest("GET", srv.URL+"/", nil)
298 req.Header.Set("Cookie", cookieName+"="+forged)
299 res, err := http.DefaultClient.Do(req)
300 if err != nil {
301 t.Fatal(err)
302 }
303 var b bytes.Buffer
304 b.ReadFrom(res.Body)
305 res.Body.Close()
306 if len(ids(b.String())) != 0 {
307 t.Fatal("forged cookie sees a shared list")
308 }
309}
310