package web import ( "bytes" "context" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "todo-go/internal/todo" ) // client is one visitor: a cookie jar that doesn't follow redirects. type client struct { t *testing.T srv *httptest.Server hc *http.Client } func newServer(t *testing.T) *httptest.Server { t.Helper() store, err := todo.Open(context.Background(), filepath.Join(t.TempDir(), "todo.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) srv := httptest.NewUnstartedServer(nil) srv.Config.Handler = New(store, Config{Origin: "http://" + srv.Listener.Addr().String(), Version: "test"}) srv.Start() t.Cleanup(srv.Close) return srv } func visitor(t *testing.T, srv *httptest.Server) *client { jar, _ := cookiejar.New(nil) hc := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} return &client{t: t, srv: srv, hc: hc} } func (c *client) get(path string) (*http.Response, string) { c.t.Helper() res, err := c.hc.Get(c.srv.URL + path) if err != nil { c.t.Fatal(err) } defer res.Body.Close() var b bytes.Buffer b.ReadFrom(res.Body) return res, b.String() } func (c *client) post(path string, form url.Values, header map[string]string) (*http.Response, string) { c.t.Helper() req, _ := http.NewRequest("POST", c.srv.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for k, v := range header { req.Header.Set(k, v) } res, err := c.hc.Do(req) if err != nil { c.t.Fatal(err) } defer res.Body.Close() var b bytes.Buffer b.ReadFrom(res.Body) return res, b.String() } var idRE = regexp.MustCompile(`data-id="(\d+)"`) func ids(body string) []string { var out []string for _, m := range idRE.FindAllStringSubmatch(body, -1) { out = append(out, m[1]) } return out } func TestActionsRedirectToTheirFilter(t *testing.T) { c := visitor(t, newServer(t)) for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} { res, _ := c.post("/todos", url.Values{"title": {"x"}, "filter": {filter}}, nil) if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want { t.Errorf("filter %q: %d to %q, want 303 to %q", filter, res.StatusCode, res.Header.Get("Location"), want) } } } func TestPageShowsFilterAndCounts(t *testing.T) { c := visitor(t, newServer(t)) _, body := c.get("/") if strings.Contains(body, `class="main"`) || strings.Contains(body, `class="footer"`) { t.Fatal("empty list should hide .main and .footer") } c.post("/todos", url.Values{"title": {"a"}}, nil) c.post("/todos", url.Values{"title": {"b"}}, nil) _, body = c.get("/") id := ids(body)[0] c.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}}, nil) _, body = c.get("/active") if got := ids(body); len(got) != 1 { t.Fatalf("/active shows %v", got) } for _, want := range []string{ `1 item left`, `Active`, `class="clear-completed"`, `href="/active?edit=`, } { if !strings.Contains(body, want) { t.Errorf("/active is missing %q", want) } } _, body = c.get("/completed?edit=" + id) if !strings.Contains(body, `class="completed editing" data-id="`+id+`"`) || !strings.Contains(body, `action="/todos/`+id+`/edit"`) { t.Errorf("edit view not rendered:\n%s", body) } } func TestEscapesTitles(t *testing.T) { c := visitor(t, newServer(t)) c.post("/todos", url.Values{"title": {` & "q"`}}, nil) _, body := c.get("/") if strings.Contains(body, "`) || strings.Contains(body, " 300 { t.Fatalf("app.js is %d lines; the cap is 300", n) } } // A well-formed cookie the server never issued is replaced, so two // visitors can't share a list by choosing the same value. func TestUnissuedCookieIsReplaced(t *testing.T) { srv := newServer(t) forged := strings.Repeat("A", 43) // decodes to 32 bytes post := func() *http.Response { req, _ := http.NewRequest("POST", srv.URL+"/todos", strings.NewReader("title=shared")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", cookieName+"="+forged) res, err := http.DefaultTransport.RoundTrip(req) if err != nil { t.Fatal(err) } res.Body.Close() return res } for range 2 { res := post() var got string for _, c := range res.Cookies() { if c.Name == cookieName { got = c.Value } } if got == "" || got == forged { t.Fatalf("forged cookie kept (Set-Cookie %q)", res.Header.Get("Set-Cookie")) } } req, _ := http.NewRequest("GET", srv.URL+"/", nil) req.Header.Set("Cookie", cookieName+"="+forged) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } var b bytes.Buffer b.ReadFrom(res.Body) res.Body.Close() if len(ids(b.String())) != 0 { t.Fatal("forged cookie sees a shared list") } }