rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package www_test
2
3import (
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/.
22func 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.
36func 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.
43func 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.
55func 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.
80type 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
97func 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.
116func (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.
124func (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.
137func (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
153func (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.
214func 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.
226func 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.
280func 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.
335func 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