446 lines
12 KiB
Go
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)
|
|
}
|
|
}
|