rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package site_test
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "io/fs"
8 "math"
9 "math/big"
10 "os"
11 "path/filepath"
12 "sort"
13 "strconv"
14 "strings"
15 "testing"
16 "testing/fstest"
17
18 "amadan.net/rastrillo/fiesta"
19 "amadan.net/rastrillo/fiesta/internal/build"
20 "amadan.net/rastrillo/fiesta/internal/site"
21)
22
23// sumsOf is SHA256SUMS as build.Dist writes it: one `<hex> <path>`
24// line per file, sorted by path, the sums file itself left out.
25func sumsOf(files map[string][]byte) []byte {
26 names := make([]string, 0, len(files))
27 for name := range files {
28 if name != "SHA256SUMS" {
29 names = append(names, name)
30 }
31 }
32 sort.Strings(names)
33 var b bytes.Buffer
34 for _, name := range names {
35 sum := sha256.Sum256(files[name])
36 b.WriteString(hex.EncodeToString(sum[:]) + " " + name + "\n")
37 }
38 return b.Bytes()
39}
40
41// release is a small synthetic release: a stylesheet that names its
42// version, a licence and a NOTICE, plus their SHA256SUMS.
43func release(version string) map[string][]byte {
44 files := map[string][]byte{
45 "fiesta.css": []byte("/*! Fiesta " + version + " */\nbody{margin:0}\n"),
46 "LICENSE": []byte("MPL-2.0\n"),
47 "NOTICE": []byte("icons: Lucide\n"),
48 "i18n/es.json": []byte(`{"today":"hoy"}` + "\n"),
49 }
50 files["SHA256SUMS"] = sumsOf(files)
51 return files
52}
53
54// releasesOf is a releases tree holding one synthetic release per
55// version named.
56func releasesOf(versions ...string) fstest.MapFS {
57 m := fstest.MapFS{}
58 for _, v := range versions {
59 for name, data := range release(v) {
60 m[v+"/"+name] = &fstest.MapFile{Data: data}
61 }
62 }
63 return m
64}
65
66// pagesOf is a pages tree from path to content.
67func pagesOf(pages map[string]string) fstest.MapFS {
68 m := fstest.MapFS{}
69 for name, text := range pages {
70 m[name] = &fstest.MapFile{Data: []byte(text)}
71 }
72 return m
73}
74
75// TestOneServesTheNewestOneX: with 1.0.0 and 1.1.0 released, /1/
76// rewrites to 1.1.0, both releases are copied byte for byte, sums file
77// included, the pages land at the root, and nothing else is written.
78func TestOneServesTheNewestOneX(t *testing.T) {
79 releases := releasesOf("1.0.0", "1.1.0")
80 pages := pagesOf(map[string]string{
81 "index.html": "<h1>Fiesta</h1>\n",
82 "themes/index.html": "<h1>Themes</h1>\n",
83 })
84 out, err := site.Build(releases, pages, site.Rules{})
85 if err != nil {
86 t.Fatalf("Build: %v", err)
87 }
88 const want = "/1/* /1.1.0/:splat 200\n"
89 if got := string(out["_redirects"]); got != want {
90 t.Errorf("_redirects is %q, want %q", got, want)
91 }
92 for name, f := range releases {
93 if got, ok := out[name]; !ok {
94 t.Errorf("%s was not copied", name)
95 } else if !bytes.Equal(got, f.Data) {
96 t.Errorf("%s differs from the release", name)
97 }
98 }
99 for name, f := range pages {
100 if got, ok := out[name]; !ok {
101 t.Errorf("page %s was not copied to the root", name)
102 } else if !bytes.Equal(got, f.Data) {
103 t.Errorf("page %s differs from the input", name)
104 }
105 }
106 if want := len(releases) + len(pages) + 2; len(out) != want {
107 names := make([]string, 0, len(out))
108 for name := range out {
109 names = append(names, name)
110 }
111 sort.Strings(names)
112 t.Errorf("the site holds %d files, want %d (the releases, the pages, _redirects and _headers):\n%s", len(out), want, strings.Join(names, "\n"))
113 }
114}
115
116// TestNewestIsNumericNotLexical: 1.10.0 is newer than 1.9.3, which a
117// string sort gets wrong.
118func TestNewestIsNumericNotLexical(t *testing.T) {
119 out, err := site.Build(releasesOf("1.2.0", "1.10.0", "1.9.3"), pagesOf(nil), site.Rules{})
120 if err != nil {
121 t.Fatalf("Build: %v", err)
122 }
123 const want = "/1/* /1.10.0/:splat 200\n"
124 if got := string(out["_redirects"]); got != want {
125 t.Errorf("_redirects is %q, want %q", got, want)
126 }
127}
128
129// TestTwoDoesNotMoveOne: a 2.0.0 release adds its own rule, newest
130// major first, and leaves /1/ pointing at the newest 1.x. The whole
131// file is compared, so the format is pinned: two spaces between
132// fields, one line per major, nothing else.
133func TestTwoDoesNotMoveOne(t *testing.T) {
134 out, err := site.Build(releasesOf("1.0.0", "1.1.0", "2.0.0"), pagesOf(nil), site.Rules{})
135 if err != nil {
136 t.Fatalf("Build: %v", err)
137 }
138 const want = "/2/* /2.0.0/:splat 200\n" +
139 "/1/* /1.1.0/:splat 200\n"
140 if got := string(out["_redirects"]); got != want {
141 t.Errorf("_redirects is:\n%s\nwant:\n%s", got, want)
142 }
143}
144
145// TestAChangedReleaseIsRefused: a release whose files do not agree
146// with its SHA256SUMS is not published. One byte flipped, a file the
147// sums do not list, and a listed file that is gone each fail Build,
148// naming the file.
149func TestAChangedReleaseIsRefused(t *testing.T) {
150 cases := []struct {
151 name string
152 change func(fstest.MapFS)
153 file string
154 }{
155 {"a flipped byte", func(m fstest.MapFS) {
156 b := append([]byte(nil), m["1.0.0/fiesta.css"].Data...)
157 b[len(b)-2] ^= 1
158 m["1.0.0/fiesta.css"] = &fstest.MapFile{Data: b}
159 }, "1.0.0/fiesta.css"},
160 {"an unlisted file", func(m fstest.MapFS) {
161 m["1.0.0/extra.css"] = &fstest.MapFile{Data: []byte("a{}")}
162 }, "1.0.0/extra.css"},
163 {"a listed file that is missing", func(m fstest.MapFS) {
164 delete(m, "1.0.0/NOTICE")
165 }, "1.0.0/NOTICE"},
166 {"no sums at all", func(m fstest.MapFS) {
167 delete(m, "1.0.0/SHA256SUMS")
168 }, "1.0.0/SHA256SUMS"},
169 }
170 for _, c := range cases {
171 t.Run(c.name, func(t *testing.T) {
172 releases := releasesOf("1.0.0", "1.1.0")
173 c.change(releases)
174 out, err := site.Build(releases, pagesOf(nil), site.Rules{})
175 if err == nil {
176 t.Fatalf("Build published a release with %s", c.name)
177 }
178 if !strings.Contains(err.Error(), c.file) {
179 t.Errorf("the error does not name %s: %v", c.file, err)
180 }
181 if out != nil {
182 t.Errorf("Build returned files alongside the error")
183 }
184 })
185 }
186}
187
188// TestAPrereleaseDirectoryIsRefused: only strict X.Y.Z directories are
189// releases. A prerelease, a v prefix, a short version, a leading zero
190// and a loose file at the top of releases/ are each refused by name.
191func TestAPrereleaseDirectoryIsRefused(t *testing.T) {
192 for _, name := range []string{"1.1.0-rc.1", "v1.0.0", "1.0", "01.0.0", "1.0.0.1"} {
193 t.Run(name, func(t *testing.T) {
194 releases := releasesOf("1.0.0")
195 for file, data := range release("1.1.0") {
196 releases[name+"/"+file] = &fstest.MapFile{Data: data}
197 }
198 _, err := site.Build(releases, pagesOf(nil), site.Rules{})
199 if err == nil {
200 t.Fatalf("Build accepted releases/%s/", name)
201 }
202 if !strings.Contains(err.Error(), name) {
203 t.Errorf("the error does not name %s: %v", name, err)
204 }
205 })
206 }
207 t.Run("a file", func(t *testing.T) {
208 releases := releasesOf("1.0.0")
209 releases["README.md"] = &fstest.MapFile{Data: []byte("# releases\n")}
210 _, err := site.Build(releases, pagesOf(nil), site.Rules{})
211 if err == nil || !strings.Contains(err.Error(), "README.md") {
212 t.Errorf("Build did not refuse a loose file at the top of releases/ by name: %v", err)
213 }
214 })
215}
216
217// TestPagesCannotShadowAMajor: an edge serves a real file before a
218// rewrite, so a page under an all-digit first segment would shadow a
219// major's rule. Every such segment is reserved, released or not, and
220// a bare all-digit file at the root is too.
221func TestPagesCannotShadowAMajor(t *testing.T) {
222 for _, page := range []string{"1/fiesta.css", "7/x.html", "3"} {
223 t.Run(page, func(t *testing.T) {
224 pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n", page: "shadow"})
225 _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{})
226 if err == nil {
227 t.Fatalf("Build accepted the page %s", page)
228 }
229 if !strings.Contains(err.Error(), page) {
230 t.Errorf("the error does not name %s: %v", page, err)
231 }
232 })
233 }
234}
235
236// TestPagesCannotShadowAVersion: a page under a version directory,
237// released or merely shaped like one, and a page named _redirects or
238// _headers are refused by name.
239func TestPagesCannotShadowAVersion(t *testing.T) {
240 for _, page := range []string{"1.0.0/index.html", "9.9.9/fiesta.css", "_redirects", "_headers"} {
241 t.Run(page, func(t *testing.T) {
242 pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n", page: "shadow"})
243 _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{})
244 if err == nil {
245 t.Fatalf("Build accepted the page %s", page)
246 }
247 if !strings.Contains(err.Error(), page) {
248 t.Errorf("the error does not name %s: %v", page, err)
249 }
250 })
251 }
252}
253
254// TestWhatBuildWritesPassesTheCheck: the release build.Dist makes from
255// the real tree, with its own SHA256SUMS, is what Build publishes.
256// This is the one place the two packages' idea of the sums file meets.
257func TestWhatBuildWritesPassesTheCheck(t *testing.T) {
258 dist, err := build.Dist(fiesta.FS(), fiesta.Version)
259 if err != nil {
260 t.Fatal(err)
261 }
262 releases := fstest.MapFS{}
263 for name, data := range dist {
264 releases[fiesta.Version+"/"+name] = &fstest.MapFile{Data: data}
265 }
266 out, err := site.Build(releases, pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n"}), site.Rules{})
267 if err != nil {
268 t.Fatalf("Build refused the real dist: %v", err)
269 }
270 for name, data := range dist {
271 if !bytes.Equal(out[fiesta.Version+"/"+name], data) {
272 t.Errorf("%s/%s was not published as built", fiesta.Version, name)
273 }
274 }
275 one, err := fs.Sub(releases, fiesta.Version)
276 if err != nil {
277 t.Fatal(err)
278 }
279 got, err := site.Release(one)
280 if err != nil {
281 t.Fatalf("Release refused the real dist: %v", err)
282 }
283 if len(got) != len(dist) {
284 t.Errorf("Release read %d files, the dist has %d", len(got), len(dist))
285 }
286}
287
288// TestTheStampIsSkippedAndNeverPublished: the one hidden file a
289// committed release carries, fiesta release's stamp at its top, is
290// skipped and never copied to the site. A stamp deeper down, or any
291// other hidden file, is a stray: unlisted, so refused by name.
292func TestTheStampIsSkippedAndNeverPublished(t *testing.T) {
293 releases := releasesOf("1.0.0")
294 stamp := "1.0.0/" + site.StampName
295 releases[stamp] = &fstest.MapFile{Data: []byte("This directory is written by fiesta release, which EMPTIES it on every\nrun.\n")}
296 out, err := site.Build(releases, pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n"}), site.Rules{})
297 if err != nil {
298 t.Fatalf("Build refused a release carrying its stamp: %v", err)
299 }
300 for name := range out {
301 if strings.Contains(name, site.StampName) {
302 t.Errorf("the stamp was published as %s", name)
303 }
304 }
305 if want := len(releases) - 1 + 1 + 2; len(out) != want {
306 t.Errorf("the site holds %d files, want %d (the release without its stamp, the page, _redirects and _headers)", len(out), want)
307 }
308 for _, stray := range []string{"1.0.0/i18n/" + site.StampName, "1.0.0/.DS_Store"} {
309 t.Run(stray, func(t *testing.T) {
310 releases := releasesOf("1.0.0")
311 releases[stray] = &fstest.MapFile{Data: []byte("x")}
312 _, err := site.Build(releases, pagesOf(nil), site.Rules{})
313 if err == nil {
314 t.Fatalf("Build published a release holding %s", stray)
315 }
316 if !strings.Contains(err.Error(), stray) {
317 t.Errorf("the error does not name %s: %v", stray, err)
318 }
319 })
320 }
321 // A hidden file that a hand-edited SHA256SUMS lists, with the right
322 // hash, is still refused: fiesta release never lists one, and
323 // hidden means never published.
324 t.Run("a listed hidden file", func(t *testing.T) {
325 files := release("1.0.0")
326 files[".secret"] = []byte("not for the site\n")
327 files["SHA256SUMS"] = sumsOf(files)
328 releases := fstest.MapFS{}
329 for name, data := range files {
330 releases["1.0.0/"+name] = &fstest.MapFile{Data: data}
331 }
332 out, err := site.Build(releases, pagesOf(nil), site.Rules{})
333 if err == nil {
334 _, published := out["1.0.0/.secret"]
335 t.Fatalf("Build accepted a release whose SHA256SUMS lists .secret (published: %v)", published)
336 }
337 if !strings.Contains(err.Error(), ".secret") {
338 t.Errorf("the error does not name .secret: %v", err)
339 }
340 })
341}
342
343// TestHiddenPagesAreRefused: a hidden file or directory anywhere under
344// pages is refused, never published and never skipped: an editor's
345// swap file, a Finder's .DS_Store, a stamp, a .well-known directory,
346// an empty .cache. The error names the hidden entry itself, so a
347// hidden directory is named rather than the first file inside it.
348func TestHiddenPagesAreRefused(t *testing.T) {
349 for page, named := range map[string]string{
350 ".DS_Store": ".DS_Store",
351 site.StampName: site.StampName,
352 "docs/.index.html.swp": "docs/.index.html.swp",
353 ".well-known/security.txt": ".well-known",
354 } {
355 t.Run(page, func(t *testing.T) {
356 pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n", page: "hidden"})
357 _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{})
358 if err == nil {
359 t.Fatalf("Build published the hidden page %s", page)
360 }
361 if !strings.Contains(err.Error(), named) {
362 t.Errorf("the error does not name %s: %v", named, err)
363 }
364 })
365 }
366 t.Run("an empty hidden directory", func(t *testing.T) {
367 pages := pagesOf(map[string]string{"index.html": "<h1>Fiesta</h1>\n"})
368 pages[".cache"] = &fstest.MapFile{Mode: fs.ModeDir | 0o755}
369 _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{})
370 if err == nil || !strings.Contains(err.Error(), ".cache") {
371 t.Errorf("Build did not refuse the hidden directory .cache by name: %v", err)
372 }
373 })
374}
375
376// TestALinkedSumsFileIsRefused: SHA256SUMS that is a symbolic link is
377// not read through. Only a regular file is a release's, the sums file
378// included, so the link is refused by name before anything is checked
379// against what it points at.
380func TestALinkedSumsFileIsRefused(t *testing.T) {
381 dir := t.TempDir()
382 files := release("1.0.0")
383 elsewhere := filepath.Join(t.TempDir(), "sums")
384 if err := os.WriteFile(elsewhere, files["SHA256SUMS"], 0o644); err != nil {
385 t.Fatal(err)
386 }
387 for name, data := range files {
388 if name == "SHA256SUMS" {
389 continue
390 }
391 p := filepath.Join(dir, filepath.FromSlash(name))
392 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
393 t.Fatal(err)
394 }
395 if err := os.WriteFile(p, data, 0o644); err != nil {
396 t.Fatal(err)
397 }
398 }
399 if err := os.Symlink(elsewhere, filepath.Join(dir, "SHA256SUMS")); err != nil {
400 t.Fatal(err)
401 }
402 _, err := site.Release(os.DirFS(dir))
403 if err == nil {
404 t.Fatal("Release read SHA256SUMS through a symbolic link")
405 }
406 if !strings.Contains(err.Error(), "SHA256SUMS") || !strings.Contains(err.Error(), "regular") {
407 t.Errorf("the error does not refuse SHA256SUMS as not a regular file: %v", err)
408 }
409}
410
411// TestAVersionComponentMustFit: a component as large as an int is a
412// version, ordered by its number; one larger is not a release at all.
413// strconv.Atoi saturates on overflow, so before this two overflowing
414// minors read as the same number and /1/ stayed on whichever came
415// first. The boundary is the machine's int, so the test holds on a
416// 32-bit build as well.
417func TestAVersionComponentMustFit(t *testing.T) {
418 most := strconv.Itoa(math.MaxInt)
419 out, err := site.Build(releasesOf("1.2.0", "1."+most+".0"), pagesOf(nil), site.Rules{})
420 if err != nil {
421 t.Fatalf("Build with a minor of %s: %v", most, err)
422 }
423 if want := "/1/* /1." + most + ".0/:splat 200\n"; string(out["_redirects"]) != want {
424 t.Errorf("_redirects is %q, want %q", out["_redirects"], want)
425 }
426 over := new(big.Int).Add(big.NewInt(math.MaxInt), big.NewInt(1)).String()
427 for _, name := range []string{"1." + over + ".0", over + ".0.0", "1.0." + over} {
428 _, err := site.Build(releasesOf("1.2.0", name), pagesOf(nil), site.Rules{})
429 if err == nil {
430 t.Errorf("Build accepted releases/%s, whose component does not fit an int", name)
431 continue
432 }
433 if !strings.Contains(err.Error(), "releases/"+name+" is not a release") {
434 t.Errorf("Build refused %s for the wrong reason: %v", name, err)
435 }
436 }
437 // Two overflowing minors: refused, not read as one number.
438 over2 := new(big.Int).Add(big.NewInt(math.MaxInt), big.NewInt(2)).String()
439 if _, err := site.Build(releasesOf("1."+over+".0", "1."+over2+".0"), pagesOf(nil), site.Rules{}); err == nil {
440 t.Error("Build accepted two releases whose minors both overflow, which Atoi reads as one number")
441 }
442}
443
444// TestTheRedirectsStayUnderTheEdgesLimits: one rule per major, and the
445// edge accepts 2,000 rules in a _redirects file (CARLOS's
446// redirectsMaxRules). A major's rule and 1,999 of the pages' build;
447// 2,001 majors are refused here, by the figure, rather than assembling
448// a site the packer rejects. (_headers, with a path per major and per
449// release, stops a site at 45 majors long before, which
450// TestTheHeadersStopShortOfTheEdgesLimit holds; this is the
451// _redirects check, which comes first.)
452func TestTheRedirectsStayUnderTheEdgesLimits(t *testing.T) {
453 majors := func(n int) []string {
454 names := make([]string, 0, n)
455 for i := 1; i <= n; i++ {
456 names = append(names, strconv.Itoa(i)+".0.0")
457 }
458 return names
459 }
460 own := make([]string, 1999)
461 for i := range own {
462 own[i] = "/a" + strconv.Itoa(i) + " /b 301"
463 }
464 out, err := site.Build(releasesOf("1.0.0"), pagesOf(nil), site.Rules{Redirects: own})
465 if err != nil {
466 t.Fatalf("Build with 2000 rules: %v", err)
467 }
468 if n := strings.Count(string(out["_redirects"]), "\n"); n != 2000 {
469 t.Errorf("_redirects holds %d rules, want 2000", n)
470 }
471 _, err = site.Build(releasesOf(majors(2001)...), pagesOf(nil), site.Rules{})
472 if err == nil {
473 t.Fatal("Build accepted 2001 majors: 2001 rules, past the edge's 2000")
474 }
475 if !strings.Contains(err.Error(), "2001 rules") || !strings.Contains(err.Error(), "accepts 2000") {
476 t.Errorf("the refusal does not give both figures: %v", err)
477 }
478}
479
480// TestThePagesOwnRulesFollowTheReleaseRewrites: the rules Build is
481// handed go into _redirects as they are, one a line, in the order
482// given, after every release rewrite, so /M/ is decided first.
483func TestThePagesOwnRulesFollowTheReleaseRewrites(t *testing.T) {
484 out, err := site.Build(releasesOf("1.0.0", "2.0.0"), pagesOf(nil), site.Rules{Redirects: []string{
485 "/old/:page /new/:page 301",
486 "/gone.html /here.html 301",
487 }})
488 if err != nil {
489 t.Fatalf("Build: %v", err)
490 }
491 const want = "/2/* /2.0.0/:splat 200\n" +
492 "/1/* /1.0.0/:splat 200\n" +
493 "/old/:page /new/:page 301\n" +
494 "/gone.html /here.html 301\n"
495 if got := string(out["_redirects"]); got != want {
496 t.Errorf("_redirects is %q, want %q", got, want)
497 }
498}
499
500// TestARuleThatIsNotOneLineIsRefused: a rule is one line of
501// _redirects, so a rule with a line break in it, an empty one, and one
502// longer than the edge's 2,048 bytes a line are refused by name, and so
503// are rules that take the file past 2,000 rules or 256 KiB with the
504// release rewrites, rather than assembling a site the packer rejects.
505func TestARuleThatIsNotOneLineIsRefused(t *testing.T) {
506 many := make([]string, 2000)
507 for i := range many {
508 many[i] = "/a" + strconv.Itoa(i) + " /b 301"
509 }
510 wide := make([]string, 130)
511 for i := range wide {
512 wide[i] = "/" + strconv.Itoa(i) + strings.Repeat("a", 2030) + " /b 301"
513 }
514 for _, c := range []struct {
515 name string
516 rules []string
517 want string
518 }{
519 {"a line break", []string{"/a /b 301\n/c /d 301"}, "one line"},
520 {"a carriage return", []string{"/a /b 301\r"}, "one line"},
521 {"an empty rule", []string{""}, "empty"},
522 {"a rule over 2048 bytes", []string{"/" + strings.Repeat("a", 2048) + " /b 301"}, "2048"},
523 {"2001 rules with the release's", many, "2000"},
524 {"over 256 KiB", wide, "bytes"},
525 } {
526 _, err := site.Build(releasesOf("1.0.0"), pagesOf(nil), site.Rules{Redirects: c.rules})
527 if err == nil {
528 t.Errorf("%s: Build accepted it", c.name)
529 } else if !strings.Contains(err.Error(), c.want) {
530 t.Errorf("%s: refused, but the error does not say %q: %v", c.name, c.want, err)
531 }
532 }
533}
534
535// The rules every _headers group carries, as the edge reads them.
536const (
537 shortLived = " Cache-Control: public, max-age=300\n"
538 yearLong = " Cache-Control: public, max-age=31536000, immutable\n"
539 cors = " Access-Control-Allow-Origin: *\n" +
540 " Cross-Origin-Resource-Policy: cross-origin\n"
541)
542
543// TestTheHeadersCacheAPublishedReleaseForAYearAndTheRestForMinutes:
544// _headers gives each major a five-minute cache, since /M/ moves to
545// every new M.x, and so each release not published, since a candidate
546// may be rebuilt under its name; each published release a year's
547// immutable one, since its tag holds it; all with the cross-origin
548// headers; and each immutable page a year's cache alone. The whole
549// file is compared, so the order is pinned too: majors, unpublished
550// releases, published ones, then pages, and nothing for the rest of
551// the site, which keeps the edge's no-cache.
552func TestTheHeadersCacheAPublishedReleaseForAYearAndTheRestForMinutes(t *testing.T) {
553 pages := pagesOf(map[string]string{
554 "index.html": "<h1>Fiesta</h1>\n",
555 "a/x.0123456789abcdef.css": "body{}\n",
556 "a/y.fedcba9876543210.js": "0\n",
557 "a/x.css": "body{}\n",
558 "static/site.css": "main{}\n",
559 "design-system/index.html": "<h1>Gallery</h1>\n",
560 "design-system/en/one.html": "<h1>One</h1>\n",
561 })
562 out, err := site.Build(releasesOf("1.0.0", "1.10.0", "1.9.3", "2.0.0"), pages, site.Rules{
563 Immutable: []string{"/a/y.fedcba9876543210.js", "/a/x.0123456789abcdef.css"},
564 Published: []string{"2.0.0", "1.0.0", "1.9.3"},
565 })
566 if err != nil {
567 t.Fatalf("Build: %v", err)
568 }
569 const want = "# Response headers for CARLOS's edge, written by fiesta site.\n" +
570 "# A rule matches the address the browser asked for, before _redirects\n" +
571 "# rewrites it. Where two rules set a header, the later one wins.\n" +
572 "# A file no rule names keeps the edge's Cache-Control: no-cache.\n" +
573 "\n" +
574 "# /M/ moves to each new M.x release, so browsers keep it five minutes.\n" +
575 "# The cross-origin headers let pages on other sites fetch these files,\n" +
576 "# load them as modules and pin them with integrity hashes.\n" +
577 "/1/*\n" + shortLived + cors +
578 "/2/*\n" + shortLived + cors +
579 "\n" +
580 "# An untagged release may be rebuilt under its name, so browsers keep it five minutes.\n" +
581 "/1.10.0/*\n" + shortLived + cors +
582 "\n" +
583 "# A published release never changes, so browsers keep it for a year.\n" +
584 "/1.0.0/*\n" + yearLong + cors +
585 "/1.9.3/*\n" + yearLong + cors +
586 "/2.0.0/*\n" + yearLong + cors +
587 "\n" +
588 "# Each of these names carries a hash of its bytes, so it never changes.\n" +
589 "/a/x.0123456789abcdef.css\n" + yearLong +
590 "/a/y.fedcba9876543210.js\n" + yearLong
591 if got := string(out["_headers"]); got != want {
592 t.Errorf("_headers is\n%s\nwant\n%s", got, want)
593 }
594}
595
596// TestAnUnpublishedBuildPromisesNoReleaseAYear: a build told of no
597// published release, which is every build but the one that ships,
598// keeps each release five minutes, as /M/, and writes no immutable
599// rule at all when no page asks for one. A candidate shipped, rebuilt
600// and shipped again under the same name must not leave browsers
601// holding the first bytes for a year.
602func TestAnUnpublishedBuildPromisesNoReleaseAYear(t *testing.T) {
603 out, err := site.Build(releasesOf("1.0.0", "1.1.0", "2.0.0"), pagesOf(nil), site.Rules{})
604 if err != nil {
605 t.Fatalf("Build: %v", err)
606 }
607 headers := string(out["_headers"])
608 if strings.Contains(headers, "immutable") || strings.Contains(headers, "31536000") {
609 t.Errorf("_headers promises a release a year with nothing published:\n%s", headers)
610 }
611 for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} {
612 if !strings.Contains(headers, "/"+v+"/*\n"+shortLived+cors) {
613 t.Errorf("_headers does not keep /%s/* five minutes, open to other origins:\n%s", v, headers)
614 }
615 }
616}
617
618// TestAPublishedVersionMustBeAReleaseHere: a published version names a
619// release directory Build holds, once, spelled as that directory is.
620// Anything else is refused by name rather than dropped, since a
621// version promised and quietly not written is a release shipped
622// without the year's cache it was meant to have.
623func TestAPublishedVersionMustBeAReleaseHere(t *testing.T) {
624 for _, published := range [][]string{
625 {"1.2.0"},
626 {"v1.0.0"},
627 {"1.0"},
628 {""},
629 {"1.0.0", "1.0.0"},
630 } {
631 _, err := site.Build(releasesOf("1.0.0", "1.1.0"), pagesOf(nil), site.Rules{Published: published})
632 if err == nil {
633 t.Errorf("Build accepted the published versions %q", published)
634 } else if want := strconv.Quote(published[len(published)-1]); !strings.Contains(err.Error(), want) {
635 t.Errorf("the refusal of %q does not name %s: %v", published, want, err)
636 }
637 }
638 if _, err := site.Build(releasesOf("1.0.0", "1.1.0"), pagesOf(nil), site.Rules{Published: []string{"1.0.0"}}); err != nil {
639 t.Errorf("Build refused a release it holds as published: %v", err)
640 }
641}
642
643// TestANewReleaseAddsItsOwnHeadersRule: the rules come from the
644// releases built, so cutting 1.1.0 adds /1.1.0/* and nothing else,
645// and /1/* stays one rule.
646func TestANewReleaseAddsItsOwnHeadersRule(t *testing.T) {
647 paths := func(versions ...string) []string {
648 t.Helper()
649 out, err := site.Build(releasesOf(versions...), pagesOf(nil), site.Rules{})
650 if err != nil {
651 t.Fatalf("Build: %v", err)
652 }
653 var ps []string
654 for _, line := range strings.Split(string(out["_headers"]), "\n") {
655 if strings.HasPrefix(line, "/") {
656 ps = append(ps, line)
657 }
658 }
659 return ps
660 }
661 before, after := paths("1.0.0"), paths("1.0.0", "1.1.0")
662 if got, want := strings.Join(before, " "), "/1/* /1.0.0/*"; got != want {
663 t.Errorf("with 1.0.0, _headers names %s; want %s", got, want)
664 }
665 if got, want := strings.Join(after, " "), "/1/* /1.0.0/* /1.1.0/*"; got != want {
666 t.Errorf("with 1.0.0 and 1.1.0, _headers names %s; want %s", got, want)
667 }
668}
669
670// TestAnImmutableAddressMustBeAPage: an immutable address names one
671// page the site serves, spelled so the edge reads it as that page. A
672// path that is no page, a release file, which has its release's rule,
673// a relative one, and one holding a character the edge reads as a
674// pattern (":", "*", "%"), a query or white space are refused by name.
675func TestAnImmutableAddressMustBeAPage(t *testing.T) {
676 pages := pagesOf(map[string]string{
677 "index.html": "<h1>Fiesta</h1>\n",
678 "a/x.1.css": "body{}\n",
679 "a/:x.css": "body{}\n",
680 "a/*.css": "body{}\n",
681 "a/%41.css": "body{}\n",
682 "a/x y.css": "body{}\n",
683 "a/x?.css": "body{}\n",
684 "a/x#.css": "body{}\n",
685 "a/tab\t.css": "body{}\n",
686 })
687 for _, p := range []string{
688 "/a/missing.css", "/1.0.0/fiesta.css", "a/x.1.css", "/a/:x.css",
689 "/a/*.css", "/a/%41.css", "/a/x y.css", "/a/x?.css", "/a/x#.css", "/a/tab\t.css",
690 } {
691 _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{Immutable: []string{p}})
692 if err == nil {
693 t.Errorf("Build accepted the immutable address %q", p)
694 } else if !strings.Contains(err.Error(), strconv.Quote(p)) {
695 t.Errorf("the refusal of %q does not name it: %v", p, err)
696 }
697 }
698 if _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{Immutable: []string{"/a/x.1.css"}}); err != nil {
699 t.Errorf("Build refused a page's address: %v", err)
700 }
701}
702
703// TestTheHeadersStopShortOfTheEdgesLimit: the edge refuses a _headers
704// file of more than 100 paths, or a line over 2,048 characters. Build
705// stops at 90 paths, whether releases or pages take it there, and says
706// what to do; and refuses a page whose path line is too long.
707func TestTheHeadersStopShortOfTheEdgesLimit(t *testing.T) {
708 releases := func(n int) []string {
709 names := make([]string, 0, n)
710 for i := 0; i < n; i++ {
711 names = append(names, "1."+strconv.Itoa(i)+".0")
712 }
713 return names
714 }
715 // One major and 89 releases: 90 paths, the most Build writes.
716 out, err := site.Build(releasesOf(releases(89)...), pagesOf(nil), site.Rules{})
717 if err != nil {
718 t.Fatalf("Build with 90 paths: %v", err)
719 }
720 if n := strings.Count(string(out["_headers"]), "\n/"); n != 90 {
721 t.Errorf("_headers names %d paths, want 90", n)
722 }
723 _, err = site.Build(releasesOf(releases(90)...), pagesOf(nil), site.Rules{})
724 if err == nil {
725 t.Fatal("Build wrote 91 paths into _headers")
726 }
727 for _, want := range []string{"91 paths", "90", "100", "directory of their own"} {
728 if !strings.Contains(err.Error(), want) {
729 t.Errorf("the refusal does not say %q: %v", want, err)
730 }
731 }
732
733 files := map[string]string{"index.html": "<h1>Fiesta</h1>\n"}
734 var immutable []string
735 for i := 0; i < 89; i++ {
736 name := "a/x" + strconv.Itoa(i) + ".css"
737 files[name] = "body{}\n"
738 immutable = append(immutable, "/"+name)
739 }
740 // One major, one release and 89 pages.
741 _, err = site.Build(releasesOf("1.0.0"), pagesOf(files), site.Rules{Immutable: immutable})
742 if err == nil || !strings.Contains(err.Error(), "91 paths") {
743 t.Errorf("Build did not stop at 91 paths, 89 of them pages: %v", err)
744 }
745
746 long := "a/" + strings.Repeat("x", 2048) + ".css"
747 _, err = site.Build(releasesOf("1.0.0"), pagesOf(map[string]string{long: "body{}\n"}),
748 site.Rules{Immutable: []string{"/" + long}})
749 if err == nil || !strings.Contains(err.Error(), "2048") {
750 t.Errorf("Build did not refuse a path line over 2,048 characters: %v", err)
751 }
752}
753
754// TestTheHeadersSetOnlyWhatTheEdgeAllows: CARLOS sends five headers
755// from a _headers file and refuses any other, and refuses an origin
756// other than "*". Build's own rules pass the same check as these, so
757// a header edited into site.go that the edge would refuse fails the
758// build, not carlos ship.
759func TestTheHeadersSetOnlyWhatTheEdgeAllows(t *testing.T) {
760 for _, c := range []struct {
761 name, value string
762 ok bool
763 }{
764 {"Cache-Control", "public, max-age=300", true},
765 {"Cache-Control", "public, max-age=31536000, immutable", true},
766 {"Access-Control-Allow-Origin", "*", true},
767 {"Access-Control-Expose-Headers", "Content-Length, ETag", true},
768 {"Cross-Origin-Resource-Policy", "cross-origin", true},
769 {"Timing-Allow-Origin", "*", true},
770 {"Set-Cookie", "a=b", false},
771 {"Content-Security-Policy", "default-src 'self'", false},
772 {"Strict-Transport-Security", "max-age=63072000", false},
773 {"Access-Control-Allow-Origin", "https://example.com", false},
774 {"Timing-Allow-Origin", "https://example.com", false},
775 {"Cross-Origin-Resource-Policy", "anywhere", false},
776 {"Cache-Control", "public max-age=300", false},
777 {"Cache-Control", "", false},
778 {"Access-Control-Expose-Headers", "Content Length", false},
779 } {
780 _, err := site.HeadersWith("/1/*", c.name, c.value)
781 switch {
782 case c.ok && err != nil:
783 t.Errorf("%s: %s was refused: %v", c.name, c.value, err)
784 case !c.ok && err == nil:
785 t.Errorf("%s: %s was accepted; the edge refuses it", c.name, c.value)
786 case !c.ok && !strings.Contains(err.Error(), c.name):
787 t.Errorf("the refusal of %s: %s does not name the header: %v", c.name, c.value, err)
788 }
789 }
790}
791