| 1 | package www_test |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | xhtml "golang.org/x/net/html" |
| 13 | ) |
| 14 | |
| 15 | // lines reads a testdata file: one entry a line, # comments and blank |
| 16 | // lines skipped. |
| 17 | func lines(t *testing.T, name string) []string { |
| 18 | t.Helper() |
| 19 | f, err := os.Open("testdata/" + name) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | defer f.Close() |
| 24 | var out []string |
| 25 | sc := bufio.NewScanner(f) |
| 26 | for sc.Scan() { |
| 27 | if l := strings.TrimSpace(sc.Text()); l != "" && !strings.HasPrefix(l, "#") { |
| 28 | out = append(out, l) |
| 29 | } |
| 30 | } |
| 31 | if err := sc.Err(); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if len(out) == 0 { |
| 35 | t.Fatalf("testdata/%s lists nothing", name) |
| 36 | } |
| 37 | return out |
| 38 | } |
| 39 | |
| 40 | // part3Home is where a part 3 page lives now, as a URL: a theme's page |
| 41 | // under its English tree, a right-to-left twin under its Arabic one, |
| 42 | // same name, and an index as its directory, which is the address the |
| 43 | // edge serves an index at (it sends .../index.html to ./, so a |
| 44 | // Location naming the file would be a second hop). |
| 45 | func part3Home(old string) string { |
| 46 | rest, ok := strings.CutPrefix(old, "design-system/") |
| 47 | if !ok { |
| 48 | return "" |
| 49 | } |
| 50 | theme, rest, ok := strings.Cut(rest, "/") |
| 51 | if !ok { |
| 52 | return "/" + old // the design system's own index, which stayed |
| 53 | } |
| 54 | locale := "en" |
| 55 | if r, ok := strings.CutPrefix(rest, "rtl/"); ok { |
| 56 | locale, rest = "ar", r |
| 57 | } |
| 58 | to := "/design-system/" + theme + "/" + locale + "/" + rest |
| 59 | if d, ok := strings.CutSuffix(to, "/index.html"); ok { |
| 60 | to = d + "/" |
| 61 | } |
| 62 | return to |
| 63 | } |
| 64 | |
| 65 | // serve answers one GET through the test server's handler. |
| 66 | func serve(h http.Handler, target string) *httptest.ResponseRecorder { |
| 67 | rec := httptest.NewRecorder() |
| 68 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| 69 | return rec |
| 70 | } |
| 71 | |
| 72 | // TestEveryPart3AddressStillReachesAPage: every page part 3 published |
| 73 | // under /design-system/ (testdata/part3-addresses.txt), and an index's |
| 74 | // directory address too, answers either with the page itself, where |
| 75 | // the path is still a page, or with a 301 whose Location is that |
| 76 | // page's part 4 address and answers 200 with HTML: one hop, never two. |
| 77 | // The Location carries no fragment, so the browser keeps the one the |
| 78 | // old link had; and on a sample of pages |
| 79 | // (testdata/part3-ids.txt) the page it lands on has every id part 3's |
| 80 | // page had, so the fragment still finds its place. |
| 81 | func TestEveryPart3AddressStillReachesAPage(t *testing.T) { |
| 82 | tree := builtSite(t) |
| 83 | h := newSiteHandler(t, tree) |
| 84 | redirected, stayed := 0, 0 |
| 85 | for _, old := range lines(t, "part3-addresses.txt") { |
| 86 | want := part3Home(old) |
| 87 | if want == "" { |
| 88 | t.Errorf("%s is not under design-system/; the inventory is part 3's design system", old) |
| 89 | continue |
| 90 | } |
| 91 | asked := []string{"/" + old} |
| 92 | if d, ok := strings.CutSuffix(old, "/index.html"); ok { |
| 93 | asked = append(asked, "/"+d+"/") |
| 94 | } |
| 95 | for _, a := range asked { |
| 96 | res := serve(h, a) |
| 97 | if _, ok := tree[old]; ok { |
| 98 | if res.Code != http.StatusOK { |
| 99 | t.Errorf("%s is still a page and answers %d", a, res.Code) |
| 100 | } |
| 101 | stayed++ |
| 102 | continue |
| 103 | } |
| 104 | loc := res.Header().Get("Location") |
| 105 | if res.Code != http.StatusMovedPermanently || loc != want { |
| 106 | t.Errorf("%s answers %d, Location %q; want 301 to %s", a, res.Code, loc, want) |
| 107 | continue |
| 108 | } |
| 109 | if strings.Contains(loc, "#") { |
| 110 | t.Errorf("%s: the Location %s has a fragment, which replaces the one the link had", a, loc) |
| 111 | } |
| 112 | there := serve(h, loc) |
| 113 | if there.Code != http.StatusOK || !strings.HasPrefix(there.Header().Get("Content-Type"), "text/html") { |
| 114 | t.Errorf("%s redirects to %s, which answers %d %s; one hop must reach the page", a, loc, there.Code, there.Header().Get("Content-Type")) |
| 115 | } |
| 116 | redirected++ |
| 117 | } |
| 118 | } |
| 119 | t.Logf("%d part 3 addresses redirect to their pages in one hop, %d are still pages", redirected, stayed) |
| 120 | |
| 121 | for _, l := range lines(t, "part3-ids.txt") { |
| 122 | f := strings.Fields(l) |
| 123 | old, ids := f[0], f[1:] |
| 124 | loc := serve(h, "/"+old).Header().Get("Location") |
| 125 | if loc == "" { |
| 126 | t.Errorf("%s does not redirect, so its ids cannot be compared", old) |
| 127 | continue |
| 128 | } |
| 129 | res := serve(h, loc) |
| 130 | if res.Code != http.StatusOK { |
| 131 | t.Errorf("%s: %s answers %d", old, loc, res.Code) |
| 132 | continue |
| 133 | } |
| 134 | have := map[string]bool{} |
| 135 | walk(parse(t, res.Body.Bytes()), func(n *xhtml.Node) { |
| 136 | if id := attr(n, "id"); id != "" { |
| 137 | have[id] = true |
| 138 | } |
| 139 | }) |
| 140 | for _, id := range ids { |
| 141 | if !have[id] { |
| 142 | t.Errorf("%s#%s lands on %s#%s, and that page has no element with that id", old, id, loc, id) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // TestNoRedirectShadowsAPage: no rule in the site's _redirects matches |
| 149 | // the path of anything the site serves, a file or an index's directory, |
| 150 | // with or without its slash; and none is forced. The edge serves a file |
| 151 | // before any rule not forced, so a rule that matched a page would be |
| 152 | // dead today, but it is proved here not to match at all, so no page's |
| 153 | // address depends on that order. Every page then answers 200 through |
| 154 | // the test server. |
| 155 | func TestNoRedirectShadowsAPage(t *testing.T) { |
| 156 | tree := builtSite(t) |
| 157 | rules, err := parseRedirects(tree["_redirects"]) |
| 158 | if err != nil { |
| 159 | t.Fatalf("_redirects: %v", err) |
| 160 | } |
| 161 | if len(rules) < 2 { |
| 162 | t.Fatalf("_redirects holds %d rules; the release rewrite and the design system's moved pages are at least two", len(rules)) |
| 163 | } |
| 164 | for _, r := range rules { |
| 165 | if r.force { |
| 166 | t.Errorf("_redirects line %d is forced, so it would answer for a file", r.line) |
| 167 | } |
| 168 | } |
| 169 | h := newSiteHandler(t, tree) |
| 170 | for name := range tree { |
| 171 | if name == "_redirects" || name == "_headers" { |
| 172 | continue |
| 173 | } |
| 174 | paths := []string{"/" + name} |
| 175 | if d, ok := strings.CutSuffix("/"+name, "/index.html"); ok { |
| 176 | paths = append(paths, d+"/") |
| 177 | } |
| 178 | for _, p := range paths { |
| 179 | for _, r := range rules { |
| 180 | if _, ok := r.match("", segments(path.Clean(p))); ok { |
| 181 | t.Errorf("_redirects line %d matches %s, which the site serves", r.line, p) |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | if res := serve(h, "/"+name); res.Code != http.StatusOK { |
| 186 | t.Errorf("/%s answers %d", name, res.Code) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // TestNoRedirectLeadsToAnotherRule: no redirect's destination, whatever |
| 192 | // its placeholders are given, can match the source of any rule, so no |
| 193 | // address, part 3's or any other, is redirected twice or in a loop. |
| 194 | // A placeholder in a destination stands for any one segment and :splat |
| 195 | // for any number, so this holds for every request, not only the |
| 196 | // addresses the inventory lists. |
| 197 | func TestNoRedirectLeadsToAnotherRule(t *testing.T) { |
| 198 | rules, err := parseRedirects(builtSite(t)["_redirects"]) |
| 199 | if err != nil { |
| 200 | t.Fatalf("_redirects: %v", err) |
| 201 | } |
| 202 | for _, from := range rules { |
| 203 | if !isRedirect(from.status) || from.prefix != "" { |
| 204 | continue |
| 205 | } |
| 206 | dest := segments(path.Clean("/" + from.to)) |
| 207 | for _, to := range rules { |
| 208 | if overlaps(dest, to) { |
| 209 | t.Errorf("_redirects line %d redirects to %s, which line %d can match: a second hop", from.line, from.to, to.line) |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // overlaps reports whether some path fits both dest, a destination's |
| 216 | // segments in which a segment holding a placeholder stands for any |
| 217 | // segment and a final :splat for any number of them, and the source |
| 218 | // of r. |
| 219 | func overlaps(dest []string, r rule) bool { |
| 220 | wild := func(seg string) bool { return strings.Contains(seg, ":") } |
| 221 | tail := len(dest) > 0 && dest[len(dest)-1] == ":splat" |
| 222 | fixed := dest |
| 223 | if tail { |
| 224 | fixed = dest[:len(dest)-1] |
| 225 | } |
| 226 | // Where both have a segment, both must be able to hold the same one. |
| 227 | n := min(len(fixed), len(r.segs)) |
| 228 | for i := 0; i < n; i++ { |
| 229 | if !wild(fixed[i]) && !strings.HasPrefix(r.segs[i], ":") && fixed[i] != r.segs[i] { |
| 230 | return false |
| 231 | } |
| 232 | } |
| 233 | switch { |
| 234 | case len(fixed) == len(r.segs): |
| 235 | return true |
| 236 | case len(fixed) > len(r.segs): |
| 237 | return r.splat |
| 238 | default: |
| 239 | // The source wants more segments than dest fixes: only a |
| 240 | // splat in dest can supply them. |
| 241 | return tail |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // TestTheRedirectsFileIsWithinCarlosLimits: the site's _redirects is |
| 246 | // one `carlos ship` would pack: every line parses under the edge's |
| 247 | // grammar, and it holds no more than 2,000 rules, 256 KiB and 2,048 |
| 248 | // bytes a line. carlos ship has no dry run for a static tree, so the |
| 249 | // packer's parse is mirrored in redirects_test.go. |
| 250 | func TestTheRedirectsFileIsWithinCarlosLimits(t *testing.T) { |
| 251 | data := builtSite(t)["_redirects"] |
| 252 | rules, err := parseRedirects(data) |
| 253 | if err != nil { |
| 254 | t.Errorf("carlos ship would refuse _redirects: %v", err) |
| 255 | } |
| 256 | if len(rules) > redirectsMaxRules { |
| 257 | t.Errorf("_redirects holds %d rules; the edge accepts %d", len(rules), redirectsMaxRules) |
| 258 | } |
| 259 | if len(data) > redirectsMaxBytes { |
| 260 | t.Errorf("_redirects is %d bytes; the edge accepts %d", len(data), redirectsMaxBytes) |
| 261 | } |
| 262 | longest := 0 |
| 263 | for i, l := range strings.Split(string(data), "\n") { |
| 264 | longest = max(longest, len(l)) |
| 265 | if len(l) > redirectsMaxLine { |
| 266 | t.Errorf("_redirects line %d is %d bytes; the edge accepts %d", i+1, len(l), redirectsMaxLine) |
| 267 | } |
| 268 | } |
| 269 | t.Logf("_redirects: %d rules, %d bytes, longest line %d bytes", len(rules), len(data), longest) |
| 270 | } |
| 271 | |