| 1 | package edge |
| 2 | |
| 3 | import ( |
| 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 | |
| 16 | func 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! |
| 25 | https://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 | |
| 45 | func 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 | |
| 87 | func 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 | |
| 97 | func 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 | |
| 111 | func 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. |
| 128 | func 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 | |
| 154 | type redirectResult struct { |
| 155 | code int |
| 156 | location string |
| 157 | body string |
| 158 | resp *http.Response |
| 159 | } |
| 160 | |
| 161 | func 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 | |
| 177 | const 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 |
| 183 | https://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 | |
| 195 | func 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 | |
| 278 | func 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 | |
| 310 | func 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 | |