Files
pages/internal/site/resolve_test.go
T
2026-08-15 07:13:00 +00:00

446 lines
12 KiB
Go

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)
}
}