package site_test import ( "bytes" "crypto/sha256" "encoding/hex" "io/fs" "math" "math/big" "os" "path/filepath" "sort" "strconv" "strings" "testing" "testing/fstest" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/build" "amadan.net/rastrillo/fiesta/internal/site" ) // sumsOf is SHA256SUMS as build.Dist writes it: one ` ` // line per file, sorted by path, the sums file itself left out. func sumsOf(files map[string][]byte) []byte { names := make([]string, 0, len(files)) for name := range files { if name != "SHA256SUMS" { names = append(names, name) } } sort.Strings(names) var b bytes.Buffer for _, name := range names { sum := sha256.Sum256(files[name]) b.WriteString(hex.EncodeToString(sum[:]) + " " + name + "\n") } return b.Bytes() } // release is a small synthetic release: a stylesheet that names its // version, a licence and a NOTICE, plus their SHA256SUMS. func release(version string) map[string][]byte { files := map[string][]byte{ "fiesta.css": []byte("/*! Fiesta " + version + " */\nbody{margin:0}\n"), "LICENSE": []byte("MPL-2.0\n"), "NOTICE": []byte("icons: Lucide\n"), "i18n/es.json": []byte(`{"today":"hoy"}` + "\n"), } files["SHA256SUMS"] = sumsOf(files) return files } // releasesOf is a releases tree holding one synthetic release per // version named. func releasesOf(versions ...string) fstest.MapFS { m := fstest.MapFS{} for _, v := range versions { for name, data := range release(v) { m[v+"/"+name] = &fstest.MapFile{Data: data} } } return m } // pagesOf is a pages tree from path to content. func pagesOf(pages map[string]string) fstest.MapFS { m := fstest.MapFS{} for name, text := range pages { m[name] = &fstest.MapFile{Data: []byte(text)} } return m } // TestOneServesTheNewestOneX: with 1.0.0 and 1.1.0 released, /1/ // rewrites to 1.1.0, both releases are copied byte for byte, sums file // included, the pages land at the root, and nothing else is written. func TestOneServesTheNewestOneX(t *testing.T) { releases := releasesOf("1.0.0", "1.1.0") pages := pagesOf(map[string]string{ "index.html": "

Fiesta

\n", "themes/index.html": "

Themes

\n", }) out, err := site.Build(releases, pages, site.Rules{}) if err != nil { t.Fatalf("Build: %v", err) } const want = "/1/* /1.1.0/:splat 200\n" if got := string(out["_redirects"]); got != want { t.Errorf("_redirects is %q, want %q", got, want) } for name, f := range releases { if got, ok := out[name]; !ok { t.Errorf("%s was not copied", name) } else if !bytes.Equal(got, f.Data) { t.Errorf("%s differs from the release", name) } } for name, f := range pages { if got, ok := out[name]; !ok { t.Errorf("page %s was not copied to the root", name) } else if !bytes.Equal(got, f.Data) { t.Errorf("page %s differs from the input", name) } } if want := len(releases) + len(pages) + 2; len(out) != want { names := make([]string, 0, len(out)) for name := range out { names = append(names, name) } sort.Strings(names) 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")) } } // TestNewestIsNumericNotLexical: 1.10.0 is newer than 1.9.3, which a // string sort gets wrong. func TestNewestIsNumericNotLexical(t *testing.T) { out, err := site.Build(releasesOf("1.2.0", "1.10.0", "1.9.3"), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build: %v", err) } const want = "/1/* /1.10.0/:splat 200\n" if got := string(out["_redirects"]); got != want { t.Errorf("_redirects is %q, want %q", got, want) } } // TestTwoDoesNotMoveOne: a 2.0.0 release adds its own rule, newest // major first, and leaves /1/ pointing at the newest 1.x. The whole // file is compared, so the format is pinned: two spaces between // fields, one line per major, nothing else. func TestTwoDoesNotMoveOne(t *testing.T) { out, err := site.Build(releasesOf("1.0.0", "1.1.0", "2.0.0"), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build: %v", err) } const want = "/2/* /2.0.0/:splat 200\n" + "/1/* /1.1.0/:splat 200\n" if got := string(out["_redirects"]); got != want { t.Errorf("_redirects is:\n%s\nwant:\n%s", got, want) } } // TestAChangedReleaseIsRefused: a release whose files do not agree // with its SHA256SUMS is not published. One byte flipped, a file the // sums do not list, and a listed file that is gone each fail Build, // naming the file. func TestAChangedReleaseIsRefused(t *testing.T) { cases := []struct { name string change func(fstest.MapFS) file string }{ {"a flipped byte", func(m fstest.MapFS) { b := append([]byte(nil), m["1.0.0/fiesta.css"].Data...) b[len(b)-2] ^= 1 m["1.0.0/fiesta.css"] = &fstest.MapFile{Data: b} }, "1.0.0/fiesta.css"}, {"an unlisted file", func(m fstest.MapFS) { m["1.0.0/extra.css"] = &fstest.MapFile{Data: []byte("a{}")} }, "1.0.0/extra.css"}, {"a listed file that is missing", func(m fstest.MapFS) { delete(m, "1.0.0/NOTICE") }, "1.0.0/NOTICE"}, {"no sums at all", func(m fstest.MapFS) { delete(m, "1.0.0/SHA256SUMS") }, "1.0.0/SHA256SUMS"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { releases := releasesOf("1.0.0", "1.1.0") c.change(releases) out, err := site.Build(releases, pagesOf(nil), site.Rules{}) if err == nil { t.Fatalf("Build published a release with %s", c.name) } if !strings.Contains(err.Error(), c.file) { t.Errorf("the error does not name %s: %v", c.file, err) } if out != nil { t.Errorf("Build returned files alongside the error") } }) } } // TestAPrereleaseDirectoryIsRefused: only strict X.Y.Z directories are // releases. A prerelease, a v prefix, a short version, a leading zero // and a loose file at the top of releases/ are each refused by name. func TestAPrereleaseDirectoryIsRefused(t *testing.T) { for _, name := range []string{"1.1.0-rc.1", "v1.0.0", "1.0", "01.0.0", "1.0.0.1"} { t.Run(name, func(t *testing.T) { releases := releasesOf("1.0.0") for file, data := range release("1.1.0") { releases[name+"/"+file] = &fstest.MapFile{Data: data} } _, err := site.Build(releases, pagesOf(nil), site.Rules{}) if err == nil { t.Fatalf("Build accepted releases/%s/", name) } if !strings.Contains(err.Error(), name) { t.Errorf("the error does not name %s: %v", name, err) } }) } t.Run("a file", func(t *testing.T) { releases := releasesOf("1.0.0") releases["README.md"] = &fstest.MapFile{Data: []byte("# releases\n")} _, err := site.Build(releases, pagesOf(nil), site.Rules{}) if err == nil || !strings.Contains(err.Error(), "README.md") { t.Errorf("Build did not refuse a loose file at the top of releases/ by name: %v", err) } }) } // TestPagesCannotShadowAMajor: an edge serves a real file before a // rewrite, so a page under an all-digit first segment would shadow a // major's rule. Every such segment is reserved, released or not, and // a bare all-digit file at the root is too. func TestPagesCannotShadowAMajor(t *testing.T) { for _, page := range []string{"1/fiesta.css", "7/x.html", "3"} { t.Run(page, func(t *testing.T) { pages := pagesOf(map[string]string{"index.html": "

Fiesta

\n", page: "shadow"}) _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{}) if err == nil { t.Fatalf("Build accepted the page %s", page) } if !strings.Contains(err.Error(), page) { t.Errorf("the error does not name %s: %v", page, err) } }) } } // TestPagesCannotShadowAVersion: a page under a version directory, // released or merely shaped like one, and a page named _redirects or // _headers are refused by name. func TestPagesCannotShadowAVersion(t *testing.T) { for _, page := range []string{"1.0.0/index.html", "9.9.9/fiesta.css", "_redirects", "_headers"} { t.Run(page, func(t *testing.T) { pages := pagesOf(map[string]string{"index.html": "

Fiesta

\n", page: "shadow"}) _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{}) if err == nil { t.Fatalf("Build accepted the page %s", page) } if !strings.Contains(err.Error(), page) { t.Errorf("the error does not name %s: %v", page, err) } }) } } // TestWhatBuildWritesPassesTheCheck: the release build.Dist makes from // the real tree, with its own SHA256SUMS, is what Build publishes. // This is the one place the two packages' idea of the sums file meets. func TestWhatBuildWritesPassesTheCheck(t *testing.T) { dist, err := build.Dist(fiesta.FS(), fiesta.Version) if err != nil { t.Fatal(err) } releases := fstest.MapFS{} for name, data := range dist { releases[fiesta.Version+"/"+name] = &fstest.MapFile{Data: data} } out, err := site.Build(releases, pagesOf(map[string]string{"index.html": "

Fiesta

\n"}), site.Rules{}) if err != nil { t.Fatalf("Build refused the real dist: %v", err) } for name, data := range dist { if !bytes.Equal(out[fiesta.Version+"/"+name], data) { t.Errorf("%s/%s was not published as built", fiesta.Version, name) } } one, err := fs.Sub(releases, fiesta.Version) if err != nil { t.Fatal(err) } got, err := site.Release(one) if err != nil { t.Fatalf("Release refused the real dist: %v", err) } if len(got) != len(dist) { t.Errorf("Release read %d files, the dist has %d", len(got), len(dist)) } } // TestTheStampIsSkippedAndNeverPublished: the one hidden file a // committed release carries, fiesta release's stamp at its top, is // skipped and never copied to the site. A stamp deeper down, or any // other hidden file, is a stray: unlisted, so refused by name. func TestTheStampIsSkippedAndNeverPublished(t *testing.T) { releases := releasesOf("1.0.0") stamp := "1.0.0/" + site.StampName releases[stamp] = &fstest.MapFile{Data: []byte("This directory is written by fiesta release, which EMPTIES it on every\nrun.\n")} out, err := site.Build(releases, pagesOf(map[string]string{"index.html": "

Fiesta

\n"}), site.Rules{}) if err != nil { t.Fatalf("Build refused a release carrying its stamp: %v", err) } for name := range out { if strings.Contains(name, site.StampName) { t.Errorf("the stamp was published as %s", name) } } if want := len(releases) - 1 + 1 + 2; len(out) != want { t.Errorf("the site holds %d files, want %d (the release without its stamp, the page, _redirects and _headers)", len(out), want) } for _, stray := range []string{"1.0.0/i18n/" + site.StampName, "1.0.0/.DS_Store"} { t.Run(stray, func(t *testing.T) { releases := releasesOf("1.0.0") releases[stray] = &fstest.MapFile{Data: []byte("x")} _, err := site.Build(releases, pagesOf(nil), site.Rules{}) if err == nil { t.Fatalf("Build published a release holding %s", stray) } if !strings.Contains(err.Error(), stray) { t.Errorf("the error does not name %s: %v", stray, err) } }) } // A hidden file that a hand-edited SHA256SUMS lists, with the right // hash, is still refused: fiesta release never lists one, and // hidden means never published. t.Run("a listed hidden file", func(t *testing.T) { files := release("1.0.0") files[".secret"] = []byte("not for the site\n") files["SHA256SUMS"] = sumsOf(files) releases := fstest.MapFS{} for name, data := range files { releases["1.0.0/"+name] = &fstest.MapFile{Data: data} } out, err := site.Build(releases, pagesOf(nil), site.Rules{}) if err == nil { _, published := out["1.0.0/.secret"] t.Fatalf("Build accepted a release whose SHA256SUMS lists .secret (published: %v)", published) } if !strings.Contains(err.Error(), ".secret") { t.Errorf("the error does not name .secret: %v", err) } }) } // TestHiddenPagesAreRefused: a hidden file or directory anywhere under // pages is refused, never published and never skipped: an editor's // swap file, a Finder's .DS_Store, a stamp, a .well-known directory, // an empty .cache. The error names the hidden entry itself, so a // hidden directory is named rather than the first file inside it. func TestHiddenPagesAreRefused(t *testing.T) { for page, named := range map[string]string{ ".DS_Store": ".DS_Store", site.StampName: site.StampName, "docs/.index.html.swp": "docs/.index.html.swp", ".well-known/security.txt": ".well-known", } { t.Run(page, func(t *testing.T) { pages := pagesOf(map[string]string{"index.html": "

Fiesta

\n", page: "hidden"}) _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{}) if err == nil { t.Fatalf("Build published the hidden page %s", page) } if !strings.Contains(err.Error(), named) { t.Errorf("the error does not name %s: %v", named, err) } }) } t.Run("an empty hidden directory", func(t *testing.T) { pages := pagesOf(map[string]string{"index.html": "

Fiesta

\n"}) pages[".cache"] = &fstest.MapFile{Mode: fs.ModeDir | 0o755} _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{}) if err == nil || !strings.Contains(err.Error(), ".cache") { t.Errorf("Build did not refuse the hidden directory .cache by name: %v", err) } }) } // TestALinkedSumsFileIsRefused: SHA256SUMS that is a symbolic link is // not read through. Only a regular file is a release's, the sums file // included, so the link is refused by name before anything is checked // against what it points at. func TestALinkedSumsFileIsRefused(t *testing.T) { dir := t.TempDir() files := release("1.0.0") elsewhere := filepath.Join(t.TempDir(), "sums") if err := os.WriteFile(elsewhere, files["SHA256SUMS"], 0o644); err != nil { t.Fatal(err) } for name, data := range files { if name == "SHA256SUMS" { continue } p := filepath.Join(dir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, data, 0o644); err != nil { t.Fatal(err) } } if err := os.Symlink(elsewhere, filepath.Join(dir, "SHA256SUMS")); err != nil { t.Fatal(err) } _, err := site.Release(os.DirFS(dir)) if err == nil { t.Fatal("Release read SHA256SUMS through a symbolic link") } if !strings.Contains(err.Error(), "SHA256SUMS") || !strings.Contains(err.Error(), "regular") { t.Errorf("the error does not refuse SHA256SUMS as not a regular file: %v", err) } } // TestAVersionComponentMustFit: a component as large as an int is a // version, ordered by its number; one larger is not a release at all. // strconv.Atoi saturates on overflow, so before this two overflowing // minors read as the same number and /1/ stayed on whichever came // first. The boundary is the machine's int, so the test holds on a // 32-bit build as well. func TestAVersionComponentMustFit(t *testing.T) { most := strconv.Itoa(math.MaxInt) out, err := site.Build(releasesOf("1.2.0", "1."+most+".0"), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build with a minor of %s: %v", most, err) } if want := "/1/* /1." + most + ".0/:splat 200\n"; string(out["_redirects"]) != want { t.Errorf("_redirects is %q, want %q", out["_redirects"], want) } over := new(big.Int).Add(big.NewInt(math.MaxInt), big.NewInt(1)).String() for _, name := range []string{"1." + over + ".0", over + ".0.0", "1.0." + over} { _, err := site.Build(releasesOf("1.2.0", name), pagesOf(nil), site.Rules{}) if err == nil { t.Errorf("Build accepted releases/%s, whose component does not fit an int", name) continue } if !strings.Contains(err.Error(), "releases/"+name+" is not a release") { t.Errorf("Build refused %s for the wrong reason: %v", name, err) } } // Two overflowing minors: refused, not read as one number. over2 := new(big.Int).Add(big.NewInt(math.MaxInt), big.NewInt(2)).String() if _, err := site.Build(releasesOf("1."+over+".0", "1."+over2+".0"), pagesOf(nil), site.Rules{}); err == nil { t.Error("Build accepted two releases whose minors both overflow, which Atoi reads as one number") } } // TestTheRedirectsStayUnderTheEdgesLimits: one rule per major, and the // edge accepts 2,000 rules in a _redirects file (CARLOS's // redirectsMaxRules). A major's rule and 1,999 of the pages' build; // 2,001 majors are refused here, by the figure, rather than assembling // a site the packer rejects. (_headers, with a path per major and per // release, stops a site at 45 majors long before, which // TestTheHeadersStopShortOfTheEdgesLimit holds; this is the // _redirects check, which comes first.) func TestTheRedirectsStayUnderTheEdgesLimits(t *testing.T) { majors := func(n int) []string { names := make([]string, 0, n) for i := 1; i <= n; i++ { names = append(names, strconv.Itoa(i)+".0.0") } return names } own := make([]string, 1999) for i := range own { own[i] = "/a" + strconv.Itoa(i) + " /b 301" } out, err := site.Build(releasesOf("1.0.0"), pagesOf(nil), site.Rules{Redirects: own}) if err != nil { t.Fatalf("Build with 2000 rules: %v", err) } if n := strings.Count(string(out["_redirects"]), "\n"); n != 2000 { t.Errorf("_redirects holds %d rules, want 2000", n) } _, err = site.Build(releasesOf(majors(2001)...), pagesOf(nil), site.Rules{}) if err == nil { t.Fatal("Build accepted 2001 majors: 2001 rules, past the edge's 2000") } if !strings.Contains(err.Error(), "2001 rules") || !strings.Contains(err.Error(), "accepts 2000") { t.Errorf("the refusal does not give both figures: %v", err) } } // TestThePagesOwnRulesFollowTheReleaseRewrites: the rules Build is // handed go into _redirects as they are, one a line, in the order // given, after every release rewrite, so /M/ is decided first. func TestThePagesOwnRulesFollowTheReleaseRewrites(t *testing.T) { out, err := site.Build(releasesOf("1.0.0", "2.0.0"), pagesOf(nil), site.Rules{Redirects: []string{ "/old/:page /new/:page 301", "/gone.html /here.html 301", }}) if err != nil { t.Fatalf("Build: %v", err) } const want = "/2/* /2.0.0/:splat 200\n" + "/1/* /1.0.0/:splat 200\n" + "/old/:page /new/:page 301\n" + "/gone.html /here.html 301\n" if got := string(out["_redirects"]); got != want { t.Errorf("_redirects is %q, want %q", got, want) } } // TestARuleThatIsNotOneLineIsRefused: a rule is one line of // _redirects, so a rule with a line break in it, an empty one, and one // longer than the edge's 2,048 bytes a line are refused by name, and so // are rules that take the file past 2,000 rules or 256 KiB with the // release rewrites, rather than assembling a site the packer rejects. func TestARuleThatIsNotOneLineIsRefused(t *testing.T) { many := make([]string, 2000) for i := range many { many[i] = "/a" + strconv.Itoa(i) + " /b 301" } wide := make([]string, 130) for i := range wide { wide[i] = "/" + strconv.Itoa(i) + strings.Repeat("a", 2030) + " /b 301" } for _, c := range []struct { name string rules []string want string }{ {"a line break", []string{"/a /b 301\n/c /d 301"}, "one line"}, {"a carriage return", []string{"/a /b 301\r"}, "one line"}, {"an empty rule", []string{""}, "empty"}, {"a rule over 2048 bytes", []string{"/" + strings.Repeat("a", 2048) + " /b 301"}, "2048"}, {"2001 rules with the release's", many, "2000"}, {"over 256 KiB", wide, "bytes"}, } { _, err := site.Build(releasesOf("1.0.0"), pagesOf(nil), site.Rules{Redirects: c.rules}) if err == nil { t.Errorf("%s: Build accepted it", c.name) } else if !strings.Contains(err.Error(), c.want) { t.Errorf("%s: refused, but the error does not say %q: %v", c.name, c.want, err) } } } // The rules every _headers group carries, as the edge reads them. const ( shortLived = " Cache-Control: public, max-age=300\n" yearLong = " Cache-Control: public, max-age=31536000, immutable\n" cors = " Access-Control-Allow-Origin: *\n" + " Cross-Origin-Resource-Policy: cross-origin\n" ) // TestTheHeadersCacheAPublishedReleaseForAYearAndTheRestForMinutes: // _headers gives each major a five-minute cache, since /M/ moves to // every new M.x, and so each release not published, since a candidate // may be rebuilt under its name; each published release a year's // immutable one, since its tag holds it; all with the cross-origin // headers; and each immutable page a year's cache alone. The whole // file is compared, so the order is pinned too: majors, unpublished // releases, published ones, then pages, and nothing for the rest of // the site, which keeps the edge's no-cache. func TestTheHeadersCacheAPublishedReleaseForAYearAndTheRestForMinutes(t *testing.T) { pages := pagesOf(map[string]string{ "index.html": "

Fiesta

\n", "a/x.0123456789abcdef.css": "body{}\n", "a/y.fedcba9876543210.js": "0\n", "a/x.css": "body{}\n", "static/site.css": "main{}\n", "design-system/index.html": "

Gallery

\n", "design-system/en/one.html": "

One

\n", }) out, err := site.Build(releasesOf("1.0.0", "1.10.0", "1.9.3", "2.0.0"), pages, site.Rules{ Immutable: []string{"/a/y.fedcba9876543210.js", "/a/x.0123456789abcdef.css"}, Published: []string{"2.0.0", "1.0.0", "1.9.3"}, }) if err != nil { t.Fatalf("Build: %v", err) } const want = "# Response headers for CARLOS's edge, written by fiesta site.\n" + "# A rule matches the address the browser asked for, before _redirects\n" + "# rewrites it. Where two rules set a header, the later one wins.\n" + "# A file no rule names keeps the edge's Cache-Control: no-cache.\n" + "\n" + "# /M/ moves to each new M.x release, so browsers keep it five minutes.\n" + "# The cross-origin headers let pages on other sites fetch these files,\n" + "# load them as modules and pin them with integrity hashes.\n" + "/1/*\n" + shortLived + cors + "/2/*\n" + shortLived + cors + "\n" + "# An untagged release may be rebuilt under its name, so browsers keep it five minutes.\n" + "/1.10.0/*\n" + shortLived + cors + "\n" + "# A published release never changes, so browsers keep it for a year.\n" + "/1.0.0/*\n" + yearLong + cors + "/1.9.3/*\n" + yearLong + cors + "/2.0.0/*\n" + yearLong + cors + "\n" + "# Each of these names carries a hash of its bytes, so it never changes.\n" + "/a/x.0123456789abcdef.css\n" + yearLong + "/a/y.fedcba9876543210.js\n" + yearLong if got := string(out["_headers"]); got != want { t.Errorf("_headers is\n%s\nwant\n%s", got, want) } } // TestAnUnpublishedBuildPromisesNoReleaseAYear: a build told of no // published release, which is every build but the one that ships, // keeps each release five minutes, as /M/, and writes no immutable // rule at all when no page asks for one. A candidate shipped, rebuilt // and shipped again under the same name must not leave browsers // holding the first bytes for a year. func TestAnUnpublishedBuildPromisesNoReleaseAYear(t *testing.T) { out, err := site.Build(releasesOf("1.0.0", "1.1.0", "2.0.0"), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build: %v", err) } headers := string(out["_headers"]) if strings.Contains(headers, "immutable") || strings.Contains(headers, "31536000") { t.Errorf("_headers promises a release a year with nothing published:\n%s", headers) } for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} { if !strings.Contains(headers, "/"+v+"/*\n"+shortLived+cors) { t.Errorf("_headers does not keep /%s/* five minutes, open to other origins:\n%s", v, headers) } } } // TestAPublishedVersionMustBeAReleaseHere: a published version names a // release directory Build holds, once, spelled as that directory is. // Anything else is refused by name rather than dropped, since a // version promised and quietly not written is a release shipped // without the year's cache it was meant to have. func TestAPublishedVersionMustBeAReleaseHere(t *testing.T) { for _, published := range [][]string{ {"1.2.0"}, {"v1.0.0"}, {"1.0"}, {""}, {"1.0.0", "1.0.0"}, } { _, err := site.Build(releasesOf("1.0.0", "1.1.0"), pagesOf(nil), site.Rules{Published: published}) if err == nil { t.Errorf("Build accepted the published versions %q", published) } else if want := strconv.Quote(published[len(published)-1]); !strings.Contains(err.Error(), want) { t.Errorf("the refusal of %q does not name %s: %v", published, want, err) } } if _, err := site.Build(releasesOf("1.0.0", "1.1.0"), pagesOf(nil), site.Rules{Published: []string{"1.0.0"}}); err != nil { t.Errorf("Build refused a release it holds as published: %v", err) } } // TestANewReleaseAddsItsOwnHeadersRule: the rules come from the // releases built, so cutting 1.1.0 adds /1.1.0/* and nothing else, // and /1/* stays one rule. func TestANewReleaseAddsItsOwnHeadersRule(t *testing.T) { paths := func(versions ...string) []string { t.Helper() out, err := site.Build(releasesOf(versions...), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build: %v", err) } var ps []string for _, line := range strings.Split(string(out["_headers"]), "\n") { if strings.HasPrefix(line, "/") { ps = append(ps, line) } } return ps } before, after := paths("1.0.0"), paths("1.0.0", "1.1.0") if got, want := strings.Join(before, " "), "/1/* /1.0.0/*"; got != want { t.Errorf("with 1.0.0, _headers names %s; want %s", got, want) } if got, want := strings.Join(after, " "), "/1/* /1.0.0/* /1.1.0/*"; got != want { t.Errorf("with 1.0.0 and 1.1.0, _headers names %s; want %s", got, want) } } // TestAnImmutableAddressMustBeAPage: an immutable address names one // page the site serves, spelled so the edge reads it as that page. A // path that is no page, a release file, which has its release's rule, // a relative one, and one holding a character the edge reads as a // pattern (":", "*", "%"), a query or white space are refused by name. func TestAnImmutableAddressMustBeAPage(t *testing.T) { pages := pagesOf(map[string]string{ "index.html": "

Fiesta

\n", "a/x.1.css": "body{}\n", "a/:x.css": "body{}\n", "a/*.css": "body{}\n", "a/%41.css": "body{}\n", "a/x y.css": "body{}\n", "a/x?.css": "body{}\n", "a/x#.css": "body{}\n", "a/tab\t.css": "body{}\n", }) for _, p := range []string{ "/a/missing.css", "/1.0.0/fiesta.css", "a/x.1.css", "/a/:x.css", "/a/*.css", "/a/%41.css", "/a/x y.css", "/a/x?.css", "/a/x#.css", "/a/tab\t.css", } { _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{Immutable: []string{p}}) if err == nil { t.Errorf("Build accepted the immutable address %q", p) } else if !strings.Contains(err.Error(), strconv.Quote(p)) { t.Errorf("the refusal of %q does not name it: %v", p, err) } } if _, err := site.Build(releasesOf("1.0.0"), pages, site.Rules{Immutable: []string{"/a/x.1.css"}}); err != nil { t.Errorf("Build refused a page's address: %v", err) } } // TestTheHeadersStopShortOfTheEdgesLimit: the edge refuses a _headers // file of more than 100 paths, or a line over 2,048 characters. Build // stops at 90 paths, whether releases or pages take it there, and says // what to do; and refuses a page whose path line is too long. func TestTheHeadersStopShortOfTheEdgesLimit(t *testing.T) { releases := func(n int) []string { names := make([]string, 0, n) for i := 0; i < n; i++ { names = append(names, "1."+strconv.Itoa(i)+".0") } return names } // One major and 89 releases: 90 paths, the most Build writes. out, err := site.Build(releasesOf(releases(89)...), pagesOf(nil), site.Rules{}) if err != nil { t.Fatalf("Build with 90 paths: %v", err) } if n := strings.Count(string(out["_headers"]), "\n/"); n != 90 { t.Errorf("_headers names %d paths, want 90", n) } _, err = site.Build(releasesOf(releases(90)...), pagesOf(nil), site.Rules{}) if err == nil { t.Fatal("Build wrote 91 paths into _headers") } for _, want := range []string{"91 paths", "90", "100", "directory of their own"} { if !strings.Contains(err.Error(), want) { t.Errorf("the refusal does not say %q: %v", want, err) } } files := map[string]string{"index.html": "

Fiesta

\n"} var immutable []string for i := 0; i < 89; i++ { name := "a/x" + strconv.Itoa(i) + ".css" files[name] = "body{}\n" immutable = append(immutable, "/"+name) } // One major, one release and 89 pages. _, err = site.Build(releasesOf("1.0.0"), pagesOf(files), site.Rules{Immutable: immutable}) if err == nil || !strings.Contains(err.Error(), "91 paths") { t.Errorf("Build did not stop at 91 paths, 89 of them pages: %v", err) } long := "a/" + strings.Repeat("x", 2048) + ".css" _, err = site.Build(releasesOf("1.0.0"), pagesOf(map[string]string{long: "body{}\n"}), site.Rules{Immutable: []string{"/" + long}}) if err == nil || !strings.Contains(err.Error(), "2048") { t.Errorf("Build did not refuse a path line over 2,048 characters: %v", err) } } // TestTheHeadersSetOnlyWhatTheEdgeAllows: CARLOS sends five headers // from a _headers file and refuses any other, and refuses an origin // other than "*". Build's own rules pass the same check as these, so // a header edited into site.go that the edge would refuse fails the // build, not carlos ship. func TestTheHeadersSetOnlyWhatTheEdgeAllows(t *testing.T) { for _, c := range []struct { name, value string ok bool }{ {"Cache-Control", "public, max-age=300", true}, {"Cache-Control", "public, max-age=31536000, immutable", true}, {"Access-Control-Allow-Origin", "*", true}, {"Access-Control-Expose-Headers", "Content-Length, ETag", true}, {"Cross-Origin-Resource-Policy", "cross-origin", true}, {"Timing-Allow-Origin", "*", true}, {"Set-Cookie", "a=b", false}, {"Content-Security-Policy", "default-src 'self'", false}, {"Strict-Transport-Security", "max-age=63072000", false}, {"Access-Control-Allow-Origin", "https://example.com", false}, {"Timing-Allow-Origin", "https://example.com", false}, {"Cross-Origin-Resource-Policy", "anywhere", false}, {"Cache-Control", "public max-age=300", false}, {"Cache-Control", "", false}, {"Access-Control-Expose-Headers", "Content Length", false}, } { _, err := site.HeadersWith("/1/*", c.name, c.value) switch { case c.ok && err != nil: t.Errorf("%s: %s was refused: %v", c.name, c.value, err) case !c.ok && err == nil: t.Errorf("%s: %s was accepted; the edge refuses it", c.name, c.value) case !c.ok && !strings.Contains(err.Error(), c.name): t.Errorf("the refusal of %s: %s does not name the header: %v", c.name, c.value, err) } } }