carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "context"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16func TestParseRedirectsAcceptsTheSupportedForms(t *testing.T) {
17 rs, err := parseRedirects([]byte(`# a comment
18
19/old /new
20/blog/* /posts/:splat 302
21/docs/:slug /guide/:slug 307 # trailing comment
22/app/* /app/index.html 200
23/* /404.html 404
24/forced /elsewhere 301!
25https://www.example.com/* https://example.com/:splat 301!
26/ext https://example.org/x?y=1 308
27` + "/crlf /there 303\r\n"))
28 if err != nil {
29 t.Fatalf("parseRedirects: %v", err)
30 }
31 if _, err := parseRedirects([]byte("\ufeff/bom /ok\n")); err != nil {
32 t.Fatalf("a file starting with a byte-order mark: %v", err)
33 }
34 if got := len(rs.rules); got != 9 {
35 t.Fatalf("parsed %d rules, want 9", got)
36 }
37 if r := rs.rules[0]; r.status != 301 || r.force || r.line != 3 {
38 t.Fatalf("rule 0 = %+v, want a 301 from line 3, not forced", r)
39 }
40 if r := rs.rules[6]; r.host != "www.example.com" || !r.force || !r.splat || r.prefix != "https://example.com" {
41 t.Fatalf("host rule = %+v", r)
42 }
43}
44
45func TestParseRedirectsRefusesWhatItCannotServe(t *testing.T) {
46 for _, line := range []string{
47 "/only-a-source",
48 "/a /b 301 Country=us",
49 "/a /b 301 extra",
50 "/a /b 418",
51 "/a /b 200x",
52 "a /b",
53 "/caf%ZZ /c",
54 "/a%2Fb /c",
55 "/%3Aname /c",
56 "/a b",
57 "/a //evil.example",
58 `/a /\evil.example`,
59 "/a/*/b /c",
60 "/a* /c",
61 "/:splat /c",
62 "/:a/:a /c",
63 "/:a-b /c",
64 "/a//b /c",
65 "/a?x=1 /c",
66 "/a/../b /c",
67 "https://www.example.com:8443/ /c",
68 "/a https://example.org/ 200",
69 "/a https://example.org/ 404",
70 "/a ftp://example.org/",
71 "/a https:///nohost",
72 } {
73 rs, err := parseRedirects([]byte(line + "\n"))
74 if err == nil {
75 t.Errorf("parseRedirects(%q) = %+v, want an error", line, rs.rules)
76 continue
77 }
78 if !strings.Contains(err.Error(), "line 1:") {
79 t.Errorf("parseRedirects(%q) error %q does not name the line", line, err)
80 }
81 if len(rs.rules) != 0 {
82 t.Errorf("parseRedirects(%q) kept %d rules", line, len(rs.rules))
83 }
84 }
85}
86
87func TestParseRedirectsKeepsTheGoodLinesAroundABadOne(t *testing.T) {
88 rs, err := parseRedirects([]byte("/a /b\n/c /d 999\n/e /f\n"))
89 if err == nil || !strings.Contains(err.Error(), "line 2:") {
90 t.Fatalf("err = %v, want one naming line 2", err)
91 }
92 if len(rs.rules) != 2 || rs.rules[1].line != 3 {
93 t.Fatalf("rules = %+v, want lines 1 and 3", rs.rules)
94 }
95}
96
97func TestParseRedirectsLimits(t *testing.T) {
98 if _, err := parseRedirects([]byte(strings.Repeat("#", redirectsMaxBytes+1))); err == nil {
99 t.Fatal("an oversized file parsed")
100 }
101 var b strings.Builder
102 for range redirectsMaxRules + 1 {
103 b.WriteString("/a /b\n")
104 }
105 rs, err := parseRedirects([]byte(b.String()))
106 if err == nil || len(rs.rules) != redirectsMaxRules {
107 t.Fatalf("%d rules, err %v: want %d rules and an error", len(rs.rules), err, redirectsMaxRules)
108 }
109}
110
111func TestPackDirRefusesABadRedirectsFile(t *testing.T) {
112 dir := writeTree(t, map[string]string{
113 "index.html": "home",
114 "_redirects": "/ok /fine\n/a /b 418\n",
115 })
116 _, err := PackDir(dir)
117 if err == nil || !strings.Contains(err.Error(), "_redirects") || !strings.Contains(err.Error(), "line 2:") {
118 t.Fatalf("PackDir = %v, want a refusal naming _redirects line 2", err)
119 }
120 // Only the root file is the rules file; one deeper is content.
121 if _, err := PackDir(writeTree(t, map[string]string{"sub/_redirects": "not rules"})); err != nil {
122 t.Fatalf("PackDir with a nested _redirects: %v", err)
123 }
124}
125
126// redirectFixture ships a site with a _redirects file behind a static
127// handler, and returns a client that does not follow redirects.
128func redirectFixture(t *testing.T, rules string) (*httptest.Server, *http.Client) {
129 t.Helper()
130 store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
131 priv, pub := testKeys(t)
132 shipSite(t, store, "site", "v1", map[string]string{
133 "index.html": "home",
134 "old.html": "old page",
135 "forced.html": "forced page",
136 "style.css": "body{}",
137 "about/index.html": "about",
138 "app/index.html": "the app",
139 "docs/page.html": "a doc",
140 "404.html": "<!doctype html><p>custom not found",
141 "_redirects": rules,
142 })
143 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
144 t.Fatalf("Promote: %v", err)
145 }
146 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
147 h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache"))
148 srv := httptest.NewServer(h)
149 t.Cleanup(srv.Close)
150 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
151 return srv, client
152}
153
154type redirectResult struct {
155 code int
156 location string
157 body string
158 resp *http.Response
159}
160
161func fetch(t *testing.T, c *http.Client, method, base, host, path string) redirectResult {
162 t.Helper()
163 req, err := http.NewRequest(method, base+path, nil)
164 if err != nil {
165 t.Fatalf("new request: %v", err)
166 }
167 req.Host = host
168 resp, err := c.Do(req)
169 if err != nil {
170 t.Fatalf("%s %s: %v", method, path, err)
171 }
172 defer resp.Body.Close()
173 b, _ := io.ReadAll(resp.Body)
174 return redirectResult{resp.StatusCode, resp.Header.Get("Location"), string(b), resp}
175}
176
177const siteRules = `
178/old.html /new.html 301
179/forced.html /elsewhere 302!
180/blog/* /posts/:splat 301
181/docs/:slug /guide/:slug?from=:slug 302
182/app/* /app/index.html 200
183https://www.example.com/* https://site.example.com/:splat 301!
184/loop/ /loop 301
185/ext https://example.org/x?y=1 307
186/empty/* /:splat/tail 301
187/frag /target#section 302
188/slash/* /slash/:splat/ 301!
189/caf%C3%A9 /cafe 301
190/files/:n /docs/:n.html 200
191/missing-rewrite /no/such/file 200
192/* /404.html 404
193`
194
195func TestStaticRedirects(t *testing.T) {
196 srv, c := redirectFixture(t, siteRules)
197 const host = "site.example.com"
198 for _, tc := range []struct {
199 host, path string
200 code int
201 location string // checked when code is a redirect
202 body string // checked when code is not
203 }{
204 // A file at the path shadows a rule; "!" overrides that.
205 {host, "/old.html", 200, "", "old page"},
206 {host, "/forced.html", 302, "/elsewhere", ""},
207 // Splats, placeholders, query strings.
208 {host, "/blog/2024/hello", 301, "/posts/2024/hello", ""},
209 {host, "/blog/2024/hello?x=1", 301, "/posts/2024/hello?x=1", ""},
210 {host, "/blog/2024/", 301, "/posts/2024/", ""},
211 {host, "/blog", 301, "/posts/", ""},
212 {host, "/docs/a%20b", 302, "/guide/a%20b?from=a+b", ""},
213 // Paths match decoded, as files are found: %2F separates segments.
214 {host, "/docs/a%2Fb", 404, "", "<!doctype html><p>custom not found"},
215 {host, "/ext?a=b", 307, "https://example.org/x?y=1", ""},
216 {host, "/frag?q=1", 302, "/target?q=1#section", ""},
217 // An existing file outside the rules is untouched.
218 {host, "/docs/page.html", 200, "", "a doc"},
219 // A directory with an index exists; a path under it is rewritten.
220 {host, "/app/", 200, "", "the app"},
221 {host, "/app/settings/profile", 200, "", "the app"},
222 // A redirect to the URL requested is skipped, not looped.
223 {host, "/loop/", 301, "/loop", ""},
224 {host, "/loop", 404, "", "<!doctype html><p>custom not found"},
225 // An empty capture never yields a protocol-relative Location.
226 {host, "/empty/", 301, "/tail", ""},
227 // A trailing-slash rule stops once the slash is there.
228 {host, "/slash/x", 301, "/slash/x/", ""},
229 {host, "/slash/x/", 404, "", "<!doctype html><p>custom not found"},
230 // A percent-encoded source matches the decoded request.
231 {host, "/caf%C3%A9", 301, "/cafe", ""},
232 // A "#" in a capture is part of the file name, not its end.
233 {host, "/files/page", 200, "", "a doc"},
234 {host, "/files/page.html%23", 404, "", "404 page not found\n"},
235 // The host rule matches its host only, and keeps the trailing slash.
236 {"www.example.com", "/about/", 301, "https://site.example.com/about/", ""},
237 {"WWW.Example.com.", "/style.css", 301, "https://site.example.com/style.css", ""},
238 {host, "/about/", 200, "", "about"},
239 // A rewrite to nothing is a plain 404; everything else hits /*.
240 {host, "/missing-rewrite", 404, "", "404 page not found\n"},
241 {host, "/nope", 404, "", "<!doctype html><p>custom not found"},
242 // The rules file is never served.
243 {host, "/_redirects", 404, "", "<!doctype html><p>custom not found"},
244 } {
245 got := fetch(t, c, "GET", srv.URL, tc.host, tc.path)
246 if got.code != tc.code {
247 t.Errorf("GET %s%s = %d (Location %q, body %q), want %d", tc.host, tc.path, got.code, got.location, got.body, tc.code)
248 continue
249 }
250 if tc.code >= 300 && tc.code < 400 {
251 if got.location != tc.location {
252 t.Errorf("GET %s%s Location = %q, want %q", tc.host, tc.path, got.location, tc.location)
253 }
254 } else if got.body != tc.body {
255 t.Errorf("GET %s%s body = %q, want %q", tc.host, tc.path, got.body, tc.body)
256 }
257 if v := got.resp.Header.Get("X-Carlos-Version"); v != "v1" {
258 t.Errorf("GET %s%s X-Carlos-Version = %q, want v1", tc.host, tc.path, v)
259 }
260 if cc := got.resp.Header.Get("Cache-Control"); cc != "no-cache" && tc.code != 404 {
261 t.Errorf("GET %s%s Cache-Control = %q, want no-cache", tc.host, tc.path, cc)
262 }
263 }
264
265 // The not-found page is typed and carries no validator.
266 got := fetch(t, c, "GET", srv.URL, host, "/nope")
267 if ct := got.resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
268 t.Errorf("404 page Content-Type = %q, want text/html", ct)
269 }
270 if et := got.resp.Header.Get("ETag"); et != "" {
271 t.Errorf("404 page ETag = %q, want none", et)
272 }
273 if head := fetch(t, c, "HEAD", srv.URL, host, "/nope"); head.code != 404 || head.body != "" {
274 t.Errorf("HEAD /nope = %d %q, want 404 with no body", head.code, head.body)
275 }
276}
277
278func TestStaticSPAFallbackLeavesAssetsAlone(t *testing.T) {
279 srv, c := redirectFixture(t, "/* /index.html 200\n")
280 const host = "site.example.com"
281 for path, want := range map[string]string{
282 "/style.css": "body{}",
283 "/about/": "about",
284 "/some/client/route": "home",
285 "/_redirects": "home",
286 } {
287 if got := fetch(t, c, "GET", srv.URL, host, path); got.code != 200 || got.body != want {
288 t.Errorf("GET %s = %d %q, want 200 %q", path, got.code, got.body, want)
289 }
290 }
291 // A rewritten page revalidates like the file it serves.
292 first := fetch(t, c, "GET", srv.URL, host, "/some/route")
293 tag := first.resp.Header.Get("ETag")
294 if tag == "" {
295 t.Fatal("rewritten page has no ETag")
296 }
297 req, _ := http.NewRequest("GET", srv.URL+"/other/route", nil)
298 req.Host = host
299 req.Header.Set("If-None-Match", tag)
300 resp, err := c.Do(req)
301 if err != nil {
302 t.Fatal(err)
303 }
304 resp.Body.Close()
305 if resp.StatusCode != http.StatusNotModified {
306 t.Fatalf("conditional GET of a rewritten page = %d, want 304", resp.StatusCode)
307 }
308}
309
310func TestStaticWithoutRedirectsIsUnchanged(t *testing.T) {
311 _, _, _, srv := staticFixture(t)
312 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
313 if got := fetch(t, c, "GET", srv.URL, "site.example.com", "/missing"); got.code != 404 || got.body != "404 page not found\n" {
314 t.Fatalf("GET /missing = %d %q, want the plain 404", got.code, got.body)
315 }
316 if got := fetch(t, c, "GET", srv.URL, "site.example.com", "/docs/page.html/index.html"); got.code != 404 {
317 t.Fatalf("GET under a file = %d, want 404", got.code)
318 }
319}
320