rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package www_test
2
3import (
4 "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.
17func 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).
45func 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.
66func 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.
81func 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.
155func 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.
197func 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.
219func 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.
250func 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