| 1 | package site_test |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | func 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. |
| 43 | func 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. |
| 56 | func 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. |
| 67 | func 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. |
| 78 | func 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. |
| 118 | func 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. |
| 133 | func 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. |
| 149 | func 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. |
| 191 | func 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. |
| 221 | func 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. |
| 239 | func 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. |
| 257 | func 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. |
| 292 | func 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. |
| 348 | func 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. |
| 380 | func 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. |
| 417 | func 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.) |
| 452 | func 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. |
| 483 | func 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. |
| 505 | func 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. |
| 536 | const ( |
| 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. |
| 552 | func 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. |
| 602 | func 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. |
| 623 | func 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. |
| 646 | func 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. |
| 675 | func 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. |
| 707 | func 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. |
| 759 | func 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 | |