| 1 | package www_test |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "mime" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "amadan.net/rastrillo/fiesta" |
| 16 | "amadan.net/rastrillo/fiesta/internal/site" |
| 17 | "amadan.net/rastrillo/fiesta/internal/www" |
| 18 | ) |
| 19 | |
| 20 | // siteSource is the repository's site/ directory, the real site |
| 21 | // source: the landing page's template and catalogs, and static/. |
| 22 | func siteSource(t *testing.T) *os.Root { |
| 23 | t.Helper() |
| 24 | r, err := os.OpenRoot("../../site") |
| 25 | if err != nil { |
| 26 | t.Fatalf("opening the site source: %v", err) |
| 27 | } |
| 28 | t.Cleanup(func() { r.Close() }) |
| 29 | return r |
| 30 | } |
| 31 | |
| 32 | // builtSite is the whole site as the edge would receive it: site.Build |
| 33 | // over the committed releases, the pages www.Pages assembles from the |
| 34 | // real site source and their rules, as fiesta site builds it with no |
| 35 | // release published. |
| 36 | func builtSite(t *testing.T) map[string][]byte { |
| 37 | t.Helper() |
| 38 | return buildSite(t, sitePages(t), nil) |
| 39 | } |
| 40 | |
| 41 | // sitePages are the pages www.Pages assembles from the real site |
| 42 | // source, the slow half of builtSite, for a test that builds twice. |
| 43 | func sitePages(t *testing.T) map[string][]byte { |
| 44 | t.Helper() |
| 45 | pages, err := www.Pages(fiesta.FS(), siteSource(t).FS()) |
| 46 | if err != nil { |
| 47 | t.Fatalf("Pages: %v", err) |
| 48 | } |
| 49 | return pages |
| 50 | } |
| 51 | |
| 52 | // buildSite is site.Build over the committed releases and pages, with |
| 53 | // the releases named in published given a year, as fiesta site |
| 54 | // -published builds the site that ships. |
| 55 | func buildSite(t *testing.T, pages map[string][]byte, published []string) map[string][]byte { |
| 56 | t.Helper() |
| 57 | rules := www.Rules(pages) |
| 58 | rules.Published = published |
| 59 | tree, err := site.Build(os.DirFS("../../releases"), www.FS(pages), rules) |
| 60 | if err != nil { |
| 61 | t.Fatalf("site.Build: %v", err) |
| 62 | } |
| 63 | return tree |
| 64 | } |
| 65 | |
| 66 | // siteHandler serves a site.Build tree the way CARLOS's static edge |
| 67 | // does (serveTree in internal/edge/static.go). A directory URL serves |
| 68 | // its index.html, and one asked for without its slash is sent to the |
| 69 | // slash form; a real file wins over every _redirects rule not forced; |
| 70 | // the first rule that matches applies, a 200 or 404 serving another |
| 71 | // file of the tree and a 301, 302, 303, 307 or 308 answering with its |
| 72 | // Location (redirects_test.go); anything else is a 404; and any method |
| 73 | // other than GET or HEAD is a 405, as on the edge, so a form a page |
| 74 | // should never submit cannot pass a test by accident. A file served |
| 75 | // with a 200 gets Cache-Control: no-cache and then every _headers rule |
| 76 | // matching the address asked for, before any rewrite |
| 77 | // (headers_test.go); a redirect or a 404 gets none. Neither rules file |
| 78 | // is served. It records every request's path and can be told to delay |
| 79 | // one path, for the first-paint drive. |
| 80 | type siteHandler struct { |
| 81 | tree map[string][]byte |
| 82 | rules []rule |
| 83 | headers []headerRule |
| 84 | |
| 85 | // noStore sends Cache-Control: no-store on every file in place of |
| 86 | // the edge's, for the browser drives: _headers lets a browser keep |
| 87 | // /1/ for minutes and a release for a year, and a drive that loads |
| 88 | // a page twice must reach the handler both times, or Delay and |
| 89 | // Seen would miss the second load. |
| 90 | noStore bool |
| 91 | |
| 92 | mu sync.Mutex |
| 93 | seen []string |
| 94 | delays map[string]time.Duration |
| 95 | } |
| 96 | |
| 97 | func newSiteHandler(t *testing.T, tree map[string][]byte) *siteHandler { |
| 98 | t.Helper() |
| 99 | h := &siteHandler{tree: tree, delays: map[string]time.Duration{}} |
| 100 | rules, err := parseRedirects(tree["_redirects"]) |
| 101 | if err != nil { |
| 102 | t.Fatalf("_redirects: %v", err) |
| 103 | } |
| 104 | h.rules = rules |
| 105 | headers, err := parseHeaders(tree["_headers"]) |
| 106 | if err != nil { |
| 107 | t.Fatalf("_headers: %v", err) |
| 108 | } |
| 109 | h.headers = headers |
| 110 | return h |
| 111 | } |
| 112 | |
| 113 | // Delay makes every response for p wait d before its body: a slow |
| 114 | // stylesheet, so a drive can see what the page looks like before it |
| 115 | // arrives. |
| 116 | func (h *siteHandler) Delay(p string, d time.Duration) { |
| 117 | h.mu.Lock() |
| 118 | defer h.mu.Unlock() |
| 119 | h.delays[p] = d |
| 120 | } |
| 121 | |
| 122 | // Seen returns the paths requested so far, in order, and forgets them |
| 123 | // when reset is true. |
| 124 | func (h *siteHandler) Seen(reset bool) []string { |
| 125 | h.mu.Lock() |
| 126 | defer h.mu.Unlock() |
| 127 | out := append([]string(nil), h.seen...) |
| 128 | if reset { |
| 129 | h.seen = nil |
| 130 | } |
| 131 | return out |
| 132 | } |
| 133 | |
| 134 | // file finds the tree entry for a cleaned, rooted path: the file |
| 135 | // itself, or the index of the directory there, and whether it was a |
| 136 | // directory. The rules files answer nothing. |
| 137 | func (h *siteHandler) file(p string) (data []byte, name string, dir, ok bool) { |
| 138 | name = strings.TrimPrefix(p, "/") |
| 139 | if name == "_redirects" || name == "_headers" { |
| 140 | return nil, "", false, false |
| 141 | } |
| 142 | if data, ok = h.tree[name]; ok && name != "" { |
| 143 | return data, name, false, true |
| 144 | } |
| 145 | index := "index.html" |
| 146 | if name != "" { |
| 147 | index = name + "/index.html" |
| 148 | } |
| 149 | data, ok = h.tree[index] |
| 150 | return data, index, ok, ok |
| 151 | } |
| 152 | |
| 153 | func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 154 | h.mu.Lock() |
| 155 | h.seen = append(h.seen, r.URL.Path) |
| 156 | delay := h.delays[r.URL.Path] |
| 157 | h.mu.Unlock() |
| 158 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 159 | w.Header().Set("Allow", "GET, HEAD") |
| 160 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| 161 | return |
| 162 | } |
| 163 | host := hostName(r.Host) |
| 164 | p := path.Clean("/" + r.URL.Path) |
| 165 | status := http.StatusOK |
| 166 | data, name, dir, ok := h.file(p) |
| 167 | if act, matched := resolve(h.rules, host, p, r.URL.Path, r.URL.RawQuery, ok); matched { |
| 168 | if act.location != "" { |
| 169 | http.Redirect(w, r, act.location, act.status) |
| 170 | return |
| 171 | } |
| 172 | // A rewrite is looked up once: the rules are not consulted |
| 173 | // again, and a destination with no file is a plain 404. |
| 174 | status = act.status |
| 175 | data, name, _, ok = h.file(act.file) |
| 176 | } else if ok && dir && !strings.HasSuffix(r.URL.Path, "/") && |
| 177 | !names(h.rules, host, p) && !redirectsSlashForm(h.rules, host, p, r.URL.Path, r.URL.RawQuery) { |
| 178 | loc := "./" + path.Base(r.URL.EscapedPath()) + "/" |
| 179 | if q := r.URL.RawQuery; q != "" { |
| 180 | loc += "?" + q |
| 181 | } |
| 182 | w.Header().Set("Location", loc) |
| 183 | w.WriteHeader(http.StatusMovedPermanently) |
| 184 | return |
| 185 | } |
| 186 | if !ok { |
| 187 | http.NotFound(w, r) |
| 188 | return |
| 189 | } |
| 190 | if delay > 0 { |
| 191 | time.Sleep(delay) |
| 192 | } |
| 193 | ct := mime.TypeByExtension(path.Ext(name)) |
| 194 | if ct == "" { |
| 195 | ct = "application/octet-stream" |
| 196 | } |
| 197 | w.Header().Set("Content-Type", ct) |
| 198 | if status == http.StatusOK { |
| 199 | w.Header().Set("Cache-Control", "no-cache") |
| 200 | for name, values := range lookupHeaders(h.headers, host, p) { |
| 201 | w.Header()[name] = values |
| 202 | } |
| 203 | } |
| 204 | if h.noStore { |
| 205 | w.Header().Set("Cache-Control", "no-store") |
| 206 | } |
| 207 | w.WriteHeader(status) |
| 208 | w.Write(data) |
| 209 | } |
| 210 | |
| 211 | // serveSite serves tree as the edge would, on a server closed with the |
| 212 | // test. The browser drives use the same handler under the harness, |
| 213 | // which watches the browser as well as the server. |
| 214 | func serveSite(t *testing.T, tree map[string][]byte) *httptest.Server { |
| 215 | t.Helper() |
| 216 | srv := httptest.NewServer(newSiteHandler(t, tree)) |
| 217 | t.Cleanup(srv.Close) |
| 218 | return srv |
| 219 | } |
| 220 | |
| 221 | // TestServeSiteServesLikeTheEdge holds the test server to the edge's |
| 222 | // rules, since every drive's reading depends on them: a directory URL |
| 223 | // serves its index.html, a /1/ URL is rewritten to the newest release |
| 224 | // with a 200, a real file wins over a rewrite, anything else is a 404, |
| 225 | // and a POST is a 405. A test of the test double, not of the site. |
| 226 | func TestServeSiteServesLikeTheEdge(t *testing.T) { |
| 227 | srv := serveSite(t, map[string][]byte{ |
| 228 | "_redirects": []byte("/1/* /1.0.0/:splat 200\n"), |
| 229 | "index.html": []byte("root"), |
| 230 | "1.0.0/fiesta.css": []byte("newest"), |
| 231 | "1/real.css": []byte("real"), |
| 232 | "design-system/x.html": []byte("page"), |
| 233 | }) |
| 234 | get := func(method, p string) (int, string) { |
| 235 | t.Helper() |
| 236 | req, err := http.NewRequest(method, srv.URL+p, nil) |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | res, err := http.DefaultClient.Do(req) |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | defer res.Body.Close() |
| 245 | var b strings.Builder |
| 246 | if _, err := io.Copy(&b, res.Body); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | return res.StatusCode, b.String() |
| 250 | } |
| 251 | for _, c := range []struct { |
| 252 | method, path string |
| 253 | status int |
| 254 | body string |
| 255 | }{ |
| 256 | {"GET", "/", 200, "root"}, |
| 257 | {"GET", "/1/fiesta.css", 200, "newest"}, |
| 258 | {"GET", "/1/real.css", 200, "real"}, |
| 259 | {"GET", "/design-system/x.html", 200, "page"}, |
| 260 | {"GET", "/design-system/", 404, ""}, |
| 261 | {"GET", "/1/nope.css", 404, ""}, |
| 262 | {"POST", "/", 405, ""}, |
| 263 | } { |
| 264 | status, body := get(c.method, c.path) |
| 265 | if status != c.status || (c.body != "" && body != c.body) { |
| 266 | t.Errorf("%s %s: %d %q; want %d %q", c.method, c.path, status, body, c.status, c.body) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // TestServeSiteRedirectsLikeTheEdge holds the test server's redirects |
| 272 | // to CARLOS's (internal/edge/redirects.go): a 301 with its Location, a |
| 273 | // :name capturing one segment, a final * capturing the rest ("" too) |
| 274 | // into :splat, a trailing slash ignored in matching and kept on a |
| 275 | // non-empty splat, the first matching rule winning, a file at the path |
| 276 | // winning over every rule but a forced one, a directory with an index |
| 277 | // counting as that file, the query carried over, and a redirect to the |
| 278 | // very URL asked for skipped. A test of the test double, not of the |
| 279 | // site. |
| 280 | func TestServeSiteRedirectsLikeTheEdge(t *testing.T) { |
| 281 | h := newSiteHandler(t, map[string][]byte{ |
| 282 | "_redirects": []byte("# moved pages\n" + |
| 283 | "/1/* /1.0.0/:splat 200\n" + |
| 284 | "/old/first.html /new/first.html 301\n" + |
| 285 | "/old/first.html /never.html 301\n" + |
| 286 | "/old/:page /new/:page\n" + |
| 287 | "/deep/:a/:b /new/:b/:a.html 302\n" + |
| 288 | "/tree/* /new/tree/:splat 301\n" + |
| 289 | "/kept.html /new/kept.html 301\n" + |
| 290 | "/forced.html /new/forced.html 301!\n" + |
| 291 | "/dir /new/dir/ 301\n" + |
| 292 | "/same/* /same/:splat 301\n"), |
| 293 | "1.0.0/fiesta.css": []byte("newest"), |
| 294 | "kept.html": []byte("kept"), |
| 295 | "forced.html": []byte("forced"), |
| 296 | "dir/index.html": []byte("dir"), |
| 297 | "new/first.html": []byte("first"), |
| 298 | "new/second.html": []byte("second"), |
| 299 | "new/kept.html": []byte("new kept"), |
| 300 | "new/tree/index.html": []byte("tree"), |
| 301 | }) |
| 302 | for _, c := range []struct { |
| 303 | path string |
| 304 | status int |
| 305 | location string |
| 306 | }{ |
| 307 | {"/1/fiesta.css", 200, ""}, |
| 308 | {"/old/first.html", 301, "/new/first.html"}, |
| 309 | {"/old/second.html", 301, "/new/second.html"}, |
| 310 | {"/old/second.html?x=1", 301, "/new/second.html?x=1"}, |
| 311 | {"/old/", 404, ""}, |
| 312 | {"/old/a/b", 404, ""}, |
| 313 | {"/deep/one/two", 302, "/new/two/one.html"}, |
| 314 | {"/tree", 301, "/new/tree/"}, |
| 315 | {"/tree/", 301, "/new/tree/"}, |
| 316 | {"/tree/a/b.html", 301, "/new/tree/a/b.html"}, |
| 317 | {"/tree/a/", 301, "/new/tree/a/"}, |
| 318 | {"/kept.html", 200, ""}, |
| 319 | {"/forced.html", 301, "/new/forced.html"}, |
| 320 | {"/dir/", 200, ""}, |
| 321 | {"/same/x", 404, ""}, |
| 322 | } { |
| 323 | rec := httptest.NewRecorder() |
| 324 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil)) |
| 325 | if loc := rec.Header().Get("Location"); rec.Code != c.status || loc != c.location { |
| 326 | t.Errorf("GET %s: %d, Location %q; want %d, Location %q", c.path, rec.Code, loc, c.status, c.location) |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | // TestServeSiteRefusesARuleCarlosWouldRefuse: the test server reads |
| 332 | // _redirects with CARLOS's grammar, so a line `carlos ship` would |
| 333 | // refuse is refused here too, by line, and a file the edge would serve |
| 334 | // is not mistaken for one it would not. |
| 335 | func TestServeSiteRefusesARuleCarlosWouldRefuse(t *testing.T) { |
| 336 | for _, line := range []string{ |
| 337 | "/only-a-source", |
| 338 | "/a /b 418", |
| 339 | "/a /b 301 Country=ie", |
| 340 | "/a/*/b /c", |
| 341 | "/a/:x/:x /c", |
| 342 | "/a?q=1 /b", |
| 343 | "relative /b", |
| 344 | "/a //elsewhere.example/", |
| 345 | "/a https://example.com/ 200", |
| 346 | "/a /" + strings.Repeat("b", 2048), |
| 347 | } { |
| 348 | if _, err := parseRedirects([]byte(line + "\n")); err == nil { |
| 349 | t.Errorf("%q parsed; CARLOS refuses it", line) |
| 350 | } |
| 351 | } |
| 352 | for _, line := range []string{ |
| 353 | "/a /b", |
| 354 | "/a/:x /b/:x 302", |
| 355 | "/a/* /b/:splat 200", |
| 356 | "/a https://example.com/b 301!", |
| 357 | "/a /b 404 # a comment", |
| 358 | "# a comment alone", |
| 359 | } { |
| 360 | if _, err := parseRedirects([]byte(line + "\n")); err != nil { |
| 361 | t.Errorf("%q was refused (%v); CARLOS accepts it", line, err) |
| 362 | } |
| 363 | } |
| 364 | } |
| 365 | |