package www_test import ( "io" "mime" "net/http" "net/http/httptest" "os" "path" "strings" "sync" "testing" "time" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/site" "amadan.net/rastrillo/fiesta/internal/www" ) // siteSource is the repository's site/ directory, the real site // source: the landing page's template and catalogs, and static/. func siteSource(t *testing.T) *os.Root { t.Helper() r, err := os.OpenRoot("../../site") if err != nil { t.Fatalf("opening the site source: %v", err) } t.Cleanup(func() { r.Close() }) return r } // builtSite is the whole site as the edge would receive it: site.Build // over the committed releases, the pages www.Pages assembles from the // real site source and their rules, as fiesta site builds it with no // release published. func builtSite(t *testing.T) map[string][]byte { t.Helper() return buildSite(t, sitePages(t), nil) } // sitePages are the pages www.Pages assembles from the real site // source, the slow half of builtSite, for a test that builds twice. func sitePages(t *testing.T) map[string][]byte { t.Helper() pages, err := www.Pages(fiesta.FS(), siteSource(t).FS()) if err != nil { t.Fatalf("Pages: %v", err) } return pages } // buildSite is site.Build over the committed releases and pages, with // the releases named in published given a year, as fiesta site // -published builds the site that ships. func buildSite(t *testing.T, pages map[string][]byte, published []string) map[string][]byte { t.Helper() rules := www.Rules(pages) rules.Published = published tree, err := site.Build(os.DirFS("../../releases"), www.FS(pages), rules) if err != nil { t.Fatalf("site.Build: %v", err) } return tree } // siteHandler serves a site.Build tree the way CARLOS's static edge // does (serveTree in internal/edge/static.go). A directory URL serves // its index.html, and one asked for without its slash is sent to the // slash form; a real file wins over every _redirects rule not forced; // the first rule that matches applies, a 200 or 404 serving another // file of the tree and a 301, 302, 303, 307 or 308 answering with its // Location (redirects_test.go); anything else is a 404; and any method // other than GET or HEAD is a 405, as on the edge, so a form a page // should never submit cannot pass a test by accident. A file served // with a 200 gets Cache-Control: no-cache and then every _headers rule // matching the address asked for, before any rewrite // (headers_test.go); a redirect or a 404 gets none. Neither rules file // is served. It records every request's path and can be told to delay // one path, for the first-paint drive. type siteHandler struct { tree map[string][]byte rules []rule headers []headerRule // noStore sends Cache-Control: no-store on every file in place of // the edge's, for the browser drives: _headers lets a browser keep // /1/ for minutes and a release for a year, and a drive that loads // a page twice must reach the handler both times, or Delay and // Seen would miss the second load. noStore bool mu sync.Mutex seen []string delays map[string]time.Duration } func newSiteHandler(t *testing.T, tree map[string][]byte) *siteHandler { t.Helper() h := &siteHandler{tree: tree, delays: map[string]time.Duration{}} rules, err := parseRedirects(tree["_redirects"]) if err != nil { t.Fatalf("_redirects: %v", err) } h.rules = rules headers, err := parseHeaders(tree["_headers"]) if err != nil { t.Fatalf("_headers: %v", err) } h.headers = headers return h } // Delay makes every response for p wait d before its body: a slow // stylesheet, so a drive can see what the page looks like before it // arrives. func (h *siteHandler) Delay(p string, d time.Duration) { h.mu.Lock() defer h.mu.Unlock() h.delays[p] = d } // Seen returns the paths requested so far, in order, and forgets them // when reset is true. func (h *siteHandler) Seen(reset bool) []string { h.mu.Lock() defer h.mu.Unlock() out := append([]string(nil), h.seen...) if reset { h.seen = nil } return out } // file finds the tree entry for a cleaned, rooted path: the file // itself, or the index of the directory there, and whether it was a // directory. The rules files answer nothing. func (h *siteHandler) file(p string) (data []byte, name string, dir, ok bool) { name = strings.TrimPrefix(p, "/") if name == "_redirects" || name == "_headers" { return nil, "", false, false } if data, ok = h.tree[name]; ok && name != "" { return data, name, false, true } index := "index.html" if name != "" { index = name + "/index.html" } data, ok = h.tree[index] return data, index, ok, ok } func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mu.Lock() h.seen = append(h.seen, r.URL.Path) delay := h.delays[r.URL.Path] h.mu.Unlock() if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } host := hostName(r.Host) p := path.Clean("/" + r.URL.Path) status := http.StatusOK data, name, dir, ok := h.file(p) if act, matched := resolve(h.rules, host, p, r.URL.Path, r.URL.RawQuery, ok); matched { if act.location != "" { http.Redirect(w, r, act.location, act.status) return } // A rewrite is looked up once: the rules are not consulted // again, and a destination with no file is a plain 404. status = act.status data, name, _, ok = h.file(act.file) } else if ok && dir && !strings.HasSuffix(r.URL.Path, "/") && !names(h.rules, host, p) && !redirectsSlashForm(h.rules, host, p, r.URL.Path, r.URL.RawQuery) { loc := "./" + path.Base(r.URL.EscapedPath()) + "/" if q := r.URL.RawQuery; q != "" { loc += "?" + q } w.Header().Set("Location", loc) w.WriteHeader(http.StatusMovedPermanently) return } if !ok { http.NotFound(w, r) return } if delay > 0 { time.Sleep(delay) } ct := mime.TypeByExtension(path.Ext(name)) if ct == "" { ct = "application/octet-stream" } w.Header().Set("Content-Type", ct) if status == http.StatusOK { w.Header().Set("Cache-Control", "no-cache") for name, values := range lookupHeaders(h.headers, host, p) { w.Header()[name] = values } } if h.noStore { w.Header().Set("Cache-Control", "no-store") } w.WriteHeader(status) w.Write(data) } // serveSite serves tree as the edge would, on a server closed with the // test. The browser drives use the same handler under the harness, // which watches the browser as well as the server. func serveSite(t *testing.T, tree map[string][]byte) *httptest.Server { t.Helper() srv := httptest.NewServer(newSiteHandler(t, tree)) t.Cleanup(srv.Close) return srv } // TestServeSiteServesLikeTheEdge holds the test server to the edge's // rules, since every drive's reading depends on them: a directory URL // serves its index.html, a /1/ URL is rewritten to the newest release // with a 200, a real file wins over a rewrite, anything else is a 404, // and a POST is a 405. A test of the test double, not of the site. func TestServeSiteServesLikeTheEdge(t *testing.T) { srv := serveSite(t, map[string][]byte{ "_redirects": []byte("/1/* /1.0.0/:splat 200\n"), "index.html": []byte("root"), "1.0.0/fiesta.css": []byte("newest"), "1/real.css": []byte("real"), "design-system/x.html": []byte("page"), }) get := func(method, p string) (int, string) { t.Helper() req, err := http.NewRequest(method, srv.URL+p, nil) if err != nil { t.Fatal(err) } res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() var b strings.Builder if _, err := io.Copy(&b, res.Body); err != nil { t.Fatal(err) } return res.StatusCode, b.String() } for _, c := range []struct { method, path string status int body string }{ {"GET", "/", 200, "root"}, {"GET", "/1/fiesta.css", 200, "newest"}, {"GET", "/1/real.css", 200, "real"}, {"GET", "/design-system/x.html", 200, "page"}, {"GET", "/design-system/", 404, ""}, {"GET", "/1/nope.css", 404, ""}, {"POST", "/", 405, ""}, } { status, body := get(c.method, c.path) if status != c.status || (c.body != "" && body != c.body) { t.Errorf("%s %s: %d %q; want %d %q", c.method, c.path, status, body, c.status, c.body) } } } // TestServeSiteRedirectsLikeTheEdge holds the test server's redirects // to CARLOS's (internal/edge/redirects.go): a 301 with its Location, a // :name capturing one segment, a final * capturing the rest ("" too) // into :splat, a trailing slash ignored in matching and kept on a // non-empty splat, the first matching rule winning, a file at the path // winning over every rule but a forced one, a directory with an index // counting as that file, the query carried over, and a redirect to the // very URL asked for skipped. A test of the test double, not of the // site. func TestServeSiteRedirectsLikeTheEdge(t *testing.T) { h := newSiteHandler(t, map[string][]byte{ "_redirects": []byte("# moved pages\n" + "/1/* /1.0.0/:splat 200\n" + "/old/first.html /new/first.html 301\n" + "/old/first.html /never.html 301\n" + "/old/:page /new/:page\n" + "/deep/:a/:b /new/:b/:a.html 302\n" + "/tree/* /new/tree/:splat 301\n" + "/kept.html /new/kept.html 301\n" + "/forced.html /new/forced.html 301!\n" + "/dir /new/dir/ 301\n" + "/same/* /same/:splat 301\n"), "1.0.0/fiesta.css": []byte("newest"), "kept.html": []byte("kept"), "forced.html": []byte("forced"), "dir/index.html": []byte("dir"), "new/first.html": []byte("first"), "new/second.html": []byte("second"), "new/kept.html": []byte("new kept"), "new/tree/index.html": []byte("tree"), }) for _, c := range []struct { path string status int location string }{ {"/1/fiesta.css", 200, ""}, {"/old/first.html", 301, "/new/first.html"}, {"/old/second.html", 301, "/new/second.html"}, {"/old/second.html?x=1", 301, "/new/second.html?x=1"}, {"/old/", 404, ""}, {"/old/a/b", 404, ""}, {"/deep/one/two", 302, "/new/two/one.html"}, {"/tree", 301, "/new/tree/"}, {"/tree/", 301, "/new/tree/"}, {"/tree/a/b.html", 301, "/new/tree/a/b.html"}, {"/tree/a/", 301, "/new/tree/a/"}, {"/kept.html", 200, ""}, {"/forced.html", 301, "/new/forced.html"}, {"/dir/", 200, ""}, {"/same/x", 404, ""}, } { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil)) if loc := rec.Header().Get("Location"); rec.Code != c.status || loc != c.location { t.Errorf("GET %s: %d, Location %q; want %d, Location %q", c.path, rec.Code, loc, c.status, c.location) } } } // TestServeSiteRefusesARuleCarlosWouldRefuse: the test server reads // _redirects with CARLOS's grammar, so a line `carlos ship` would // refuse is refused here too, by line, and a file the edge would serve // is not mistaken for one it would not. func TestServeSiteRefusesARuleCarlosWouldRefuse(t *testing.T) { for _, line := range []string{ "/only-a-source", "/a /b 418", "/a /b 301 Country=ie", "/a/*/b /c", "/a/:x/:x /c", "/a?q=1 /b", "relative /b", "/a //elsewhere.example/", "/a https://example.com/ 200", "/a /" + strings.Repeat("b", 2048), } { if _, err := parseRedirects([]byte(line + "\n")); err == nil { t.Errorf("%q parsed; CARLOS refuses it", line) } } for _, line := range []string{ "/a /b", "/a/:x /b/:x 302", "/a/* /b/:splat 200", "/a https://example.com/b 301!", "/a /b 404 # a comment", "# a comment alone", } { if _, err := parseRedirects([]byte(line + "\n")); err != nil { t.Errorf("%q was refused (%v); CARLOS accepts it", line, err) } } }