package site import ( "io/fs" "net/http" "strings" "testing" "time" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/store" ) // siteFiles is one deployment covering every shape Resolve has to tell apart: a // root index, a directory that has an index, a directory that does not, a custom // error document, and a non-ASCII name. var siteFiles = []string{ "index.html", "404.html", "assets/app.js", "docs/index.html", "docs/deep/page.html", "noindex/data.json", "文档/说明.html", } func fixture(paths ...string) *Deployment { rows := make([]store.FileRow, len(paths)) for i, p := range paths { rows[i] = store.FileRow{Path: p, Digest: cas.Sum([]byte(p)), Size: int64(len(p))} } dep := &store.Deployment{PublicID: "dpl_test", ProjectID: 1, CreatedAt: time.Unix(1700000000, 0).UTC()} return NewDeployment(dep, NewIndex(rows), "") } // plain is the default project: an index, no error document, no fallback. func plain() *ProjectConfig { return &ProjectConfig{IndexFile: "index.html"} } func withNotFound() *ProjectConfig { c := plain() c.NotFoundFile = "404.html" return c } func withSPA() *ProjectConfig { c := plain() c.SPAFallback = true return c } func TestResolve(t *testing.T) { d := fixture(siteFiles...) tests := []struct { name string cfg *ProjectConfig rest string accept string status int file string // Result.Name location string }{ // ------------------------------------------------ canonicalisation { name: "bare project name gets a trailing slash", cfg: plain(), rest: "", status: http.StatusMovedPermanently, location: "/~demo/", }, { name: "root serves the index", cfg: plain(), rest: "/", status: http.StatusOK, file: "index.html", }, { name: "exact hit", cfg: plain(), rest: "/assets/app.js", status: http.StatusOK, file: "assets/app.js", }, { name: "unicode path", cfg: plain(), rest: "/文档/说明.html", status: http.StatusOK, file: "文档/说明.html", }, { name: "double slash is collapsed", cfg: plain(), rest: "//assets//app.js", status: http.StatusMovedPermanently, location: "/~demo/assets/app.js", }, { name: "dot segment is removed", cfg: plain(), rest: "/./assets/app.js", status: http.StatusMovedPermanently, location: "/~demo/assets/app.js", }, { // The decoded form of %2e%2e%2f: it arrives here unredirected by the // mux, and Clean resolves it before anything is looked up. It cannot // reach outside the deployment because the result is only ever a key // into a map. name: "parent segments are resolved, not followed", cfg: plain(), rest: "/assets/../index.html", status: http.StatusMovedPermanently, location: "/~demo/index.html", }, { name: "parent segments above the root land at the root", cfg: plain(), rest: "/../../etc/passwd", status: http.StatusMovedPermanently, location: "/~demo/etc/passwd", }, { name: "a file asked for with a directory's URL", cfg: plain(), rest: "/index.html/", status: http.StatusMovedPermanently, location: "/~demo/index.html", }, // ------------------------------------------------------ directories { name: "directory without a trailing slash redirects", cfg: plain(), rest: "/docs", status: http.StatusMovedPermanently, location: "/~demo/docs/", }, { name: "directory with a trailing slash serves its index", cfg: plain(), rest: "/docs/", status: http.StatusOK, file: "docs/index.html", }, { name: "intermediate directory redirects too", cfg: plain(), rest: "/docs/deep", status: http.StatusMovedPermanently, location: "/~demo/docs/deep/", }, { name: "directory with no index is a 404, not a listing", cfg: plain(), rest: "/noindex/", status: http.StatusNotFound, }, { name: "nothing there at all", cfg: plain(), rest: "/nope.txt", status: http.StatusNotFound, }, { name: "an empty index file setting leaves the root a 404", cfg: &ProjectConfig{}, rest: "/", status: http.StatusNotFound, }, // --------------------------------------------------- invalid paths { name: "NUL byte", cfg: plain(), rest: "/index\x00.html", status: http.StatusBadRequest, }, { name: "control character", cfg: plain(), rest: "/index\n.html", status: http.StatusBadRequest, }, { name: "backslash", cfg: plain(), rest: "/assets\\app.js", status: http.StatusBadRequest, }, { name: "invalid UTF-8", cfg: plain(), rest: "/\xff\xfe.html", status: http.StatusBadRequest, }, { name: "path longer than the limit", cfg: plain(), rest: "/" + strings.Repeat("a", 4097), status: http.StatusBadRequest, }, { name: "segment longer than the limit", cfg: plain(), rest: "/" + strings.Repeat("b", 256) + "/x", status: http.StatusBadRequest, }, // ------------------------------------------------- custom 404 page { name: "the error document is served with a 404 status", cfg: withNotFound(), rest: "/nope.txt", status: http.StatusNotFound, file: "404.html", }, { name: "an error document that is not in the manifest is skipped", cfg: &ProjectConfig{IndexFile: "index.html", NotFoundFile: "missing.html"}, rest: "/nope.txt", status: http.StatusNotFound, }, { name: "a directory with no index falls to the error document", cfg: withNotFound(), rest: "/noindex/", status: http.StatusNotFound, file: "404.html", }, // ---------------------------------------------------- SPA fallback { name: "a navigation gets the app shell", cfg: withSPA(), rest: "/some/client/route", accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", status: http.StatusOK, file: "index.html", }, { // The whole point of the Accept gate: without it a missing script // comes back as HTML with a 200 and fails as a syntax error in some // unrelated place. name: "a missing script still 404s", cfg: withSPA(), rest: "/assets/missing.js", accept: "*/*", status: http.StatusNotFound, }, { name: "no Accept header at all is not a navigation", cfg: withSPA(), rest: "/some/client/route", status: http.StatusNotFound, }, { name: "html anywhere in the list counts", cfg: withSPA(), rest: "/some/client/route", accept: "application/json;q=0.9, text/html;q=0.8", status: http.StatusOK, file: "index.html", }, { name: "with the fallback off a navigation 404s", cfg: plain(), rest: "/some/client/route", accept: "text/html", status: http.StatusNotFound, }, { name: "the fallback does not rescue an invalid path", cfg: withSPA(), rest: "/bad\x00path", accept: "text/html", status: http.StatusBadRequest, }, { // A directory redirect outranks the fallback: the URL is real, it just // needs its slash. name: "the fallback does not swallow a directory redirect", cfg: withSPA(), rest: "/docs", accept: "text/html", status: http.StatusMovedPermanently, location: "/~demo/docs/", }, { name: "the error document wins over nothing when the shell is missing", cfg: &ProjectConfig{IndexFile: "absent.html", NotFoundFile: "404.html", SPAFallback: true}, rest: "/some/client/route", accept: "text/html", status: http.StatusNotFound, file: "404.html", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := Resolve(d, tc.cfg, "demo", tc.rest, tc.accept) if got.Status != tc.status { t.Errorf("status = %d, want %d", got.Status, tc.status) } if got.Name != tc.file { t.Errorf("name = %q, want %q", got.Name, tc.file) } if got.Location != tc.location { t.Errorf("location = %q, want %q", got.Location, tc.location) } if tc.file != "" && got.Entry.Digest != cas.Sum([]byte(tc.file)) { t.Errorf("entry does not belong to %s", tc.file) } if tc.file == "" && got.Entry != (FileEntry{}) { t.Errorf("a response with no file carries an entry: %+v", got.Entry) } }) } } // A redirect must always be a step towards a terminal answer. A pair that // bounced between two locations would be an infinite loop in a browser. func TestResolveRedirectsConverge(t *testing.T) { d := fixture(siteFiles...) cfgs := map[string]*ProjectConfig{"plain": plain(), "spa": withSPA(), "404": withNotFound()} paths := []string{ "", "/", "//", "/.", "/..", "/docs", "/docs/", "/docs//deep", "/docs/deep", "/index.html", "/index.html/", "/./docs/../docs/", "/noindex", "/nope", "/文档", "/文档/", } for name, cfg := range cfgs { for _, p := range paths { rest := p for hop := 0; ; hop++ { if hop > 4 { t.Errorf("%s %q: still redirecting after %d hops", name, p, hop) break } res := Resolve(d, cfg, "demo", rest, "text/html") if res.Status != http.StatusMovedPermanently { break } next, ok := strings.CutPrefix(res.Location, "/~demo") if !ok { t.Fatalf("%s %q: redirect escaped the project: %q", name, p, res.Location) break } if next == rest { t.Fatalf("%s %q: redirects to itself", name, p) } rest = next } } } } // Whatever a request asks for, the answer either names a path that could have // been a manifest entry or names nothing at all. That is what keeps the serving // path from ever deriving a filesystem name from user input. func FuzzResolve(f *testing.F) { d := fixture(siteFiles...) cfg := &ProjectConfig{IndexFile: "index.html", NotFoundFile: "404.html", SPAFallback: true} for _, s := range []string{ "", "/", "/index.html", "/../../etc/passwd", "//", "/docs/", "/\x00", "/文档/说明.html", "/a/b/c/../../..", "/.git/config", } { f.Add(s, "text/html") } f.Fuzz(func(t *testing.T, rest, accept string) { res := Resolve(d, cfg, "demo", rest, accept) switch res.Status { case http.StatusOK, http.StatusNotFound: if res.Name == "" { return } if !fs.ValidPath(res.Name) { t.Fatalf("resolved %q to the invalid name %q", rest, res.Name) } if _, ok := d.Lookup(res.Name); !ok { t.Fatalf("resolved %q to %q, which is not in the manifest", rest, res.Name) } case http.StatusMovedPermanently: if !strings.HasPrefix(res.Location, "/~demo/") && res.Location != "/~demo" { t.Fatalf("resolved %q to a location outside the project: %q", rest, res.Location) } if res.Name != "" { t.Fatalf("a redirect for %q also named a file: %q", rest, res.Name) } case http.StatusBadRequest: if res.Name != "" { t.Fatalf("a rejection for %q also named a file: %q", rest, res.Name) } default: t.Fatalf("resolved %q to the unexpected status %d", rest, res.Status) } }) } func TestAcceptsHTML(t *testing.T) { tests := []struct { accept string want bool }{ {"", false}, {"*/*", false}, // a fetch() with no opinion is not a navigation {"text/*", false}, // and neither is a wildcard subtype {"application/json", false}, {"text/plain", false}, {"text/htmlx", false}, {"text/html", true}, {"TEXT/HTML", true}, {"text/html;charset=utf-8", true}, {"text/html, */*", true}, {"application/json, text/html;q=0.1", true}, {" text/html ", true}, {"application/xhtml+xml,text/html", true}, } for _, tc := range tests { if got := acceptsHTML(tc.accept); got != tc.want { t.Errorf("acceptsHTML(%q) = %v, want %v", tc.accept, got, tc.want) } } } func TestIndexRecordsEveryDirectoryPrefix(t *testing.T) { d := fixture("a/b/c/d.txt", "top.txt") for _, dir := range []string{"a", "a/b", "a/b/c"} { if !d.IsDir(dir) { t.Errorf("%q is not recorded as a directory", dir) } } for _, notDir := range []string{"", ".", "/", "a/b/c/d.txt", "top.txt", "a/b/c/d"} { if d.IsDir(notDir) { t.Errorf("%q is recorded as a directory", notDir) } } if d.FileCount != 2 { t.Errorf("FileCount = %d, want 2", d.FileCount) } if want := int64(len("a/b/c/d.txt") + len("top.txt")); d.TotalBytes != want { t.Errorf("TotalBytes = %d, want %d", d.TotalBytes, want) } }