- /_presentations must be a plain 404, not the SPA shell - A router without the presentations plugin has no public document route
265 lines
9.5 KiB
Go
265 lines
9.5 KiB
Go
package bm
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
_ "git.golem15.com/golem15/sm-user-plugin"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
const (
|
|
ctHTML = "text/html; charset=utf-8"
|
|
indexBody = "<!doctype html><div id=app></div>"
|
|
)
|
|
|
|
// fixturePublic is a built public/ tree in miniature.
|
|
func fixturePublic() fstest.MapFS {
|
|
files := map[string]string{
|
|
"index.html": indexBody,
|
|
"favicon.svg": "<svg/>",
|
|
"manifest.webmanifest": `{"name":"BM"}`,
|
|
"assets/index-abc.js": "console.log(1)",
|
|
"assets/index-abc.css": "body{}",
|
|
"assets/font-abc.woff2": "wOF2",
|
|
".hidden": "secret",
|
|
".git/config": "secret",
|
|
}
|
|
fsys := fstest.MapFS{}
|
|
for name, body := range files {
|
|
fsys[name] = &fstest.MapFile{Data: []byte(body)}
|
|
}
|
|
return fsys
|
|
}
|
|
|
|
func handler(t *testing.T, admin string) http.Handler {
|
|
t.Helper()
|
|
h, err := newStaticApp(fixturePublic(), admin)
|
|
if err != nil {
|
|
t.Fatalf("newStaticApp: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func get(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(method, "/", nil)
|
|
r.URL.Path = target
|
|
for k, vs := range header {
|
|
for _, v := range vs {
|
|
r.Header.Add(k, v)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
return rec
|
|
}
|
|
|
|
func TestStaticFilesAndFallback(t *testing.T) {
|
|
h := handler(t, "/bmadmin")
|
|
cases := []struct {
|
|
path, ctype, cache, body string
|
|
status int
|
|
}{
|
|
{"/", ctHTML, cacheNoCache, indexBody, 200},
|
|
{"/index.html", ctHTML, cacheNoCache, indexBody, 200},
|
|
{"/favicon.svg", "image/svg+xml", cacheNoCache, "<svg/>", 200},
|
|
{"/manifest.webmanifest", "application/manifest+json", cacheNoCache, `{"name":"BM"}`, 200},
|
|
{"/assets/index-abc.js", "text/javascript; charset=utf-8", cacheImmutable, "console.log(1)", 200},
|
|
{"/assets/index-abc.css", "text/css; charset=utf-8", cacheImmutable, "body{}", 200},
|
|
{"/assets/font-abc.woff2", "font/woff2", cacheImmutable, "wOF2", 200},
|
|
// Client routes fall back to the index.
|
|
{"/plan", ctHTML, cacheNoCache, indexBody, 200},
|
|
{"/przedmioty/12", ctHTML, cacheNoCache, indexBody, 200},
|
|
{"/logowanie/", ctHTML, cacheNoCache, indexBody, 200},
|
|
{"/reset-password", ctHTML, cacheNoCache, indexBody, 200},
|
|
// A missing asset is a 404, never the index.
|
|
{"/assets/missing.js", "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
{"/robots.txt", "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
// Dot files and traversal.
|
|
{"/.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
{"/.git/config", "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
{"/assets/../.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
{`/assets\..\.hidden`, "text/plain; charset=utf-8", "", "404 page not found\n", 404},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.path, func(t *testing.T) {
|
|
rec := get(h, http.MethodGet, c.path, nil)
|
|
if rec.Code != c.status {
|
|
t.Fatalf("status %d, want %d", rec.Code, c.status)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != c.ctype {
|
|
t.Fatalf("Content-Type %q, want %q", got, c.ctype)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != c.cache {
|
|
t.Fatalf("Cache-Control %q, want %q", got, c.cache)
|
|
}
|
|
if rec.Body.String() != c.body {
|
|
t.Fatalf("body %q, want %q", rec.Body.String(), c.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStaticReservedPaths(t *testing.T) {
|
|
h := handler(t, "/bmadmin")
|
|
for _, p := range []string{
|
|
"/_user", "/_user/", "/_user/api/v1/nope", "/_bm/api/v1/nope", "/_quizzes/api/v1/nope",
|
|
"/_presentations", "/_presentations/api/v1/nope",
|
|
"/bmadmin", "/bmadmin/", "/bmadmin/login", "/storage/app/uploads/public/x.pdf",
|
|
"/storage", "/_user/../_user/x", "//_bm/api",
|
|
} {
|
|
rec := get(h, http.MethodGet, p, nil)
|
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "app") {
|
|
t.Fatalf("GET %s = %d %q, want a plain 404", p, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
// Names that merely start like a reserved prefix are client routes.
|
|
for _, p := range []string{"/bmadmins", "/storage-info", "/_users"} {
|
|
if rec := get(h, http.MethodGet, p, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d, want the fallback", p, rec.Code)
|
|
}
|
|
}
|
|
// A custom admin mount is reserved too, the default is then a client route.
|
|
h = handler(t, "/panel/")
|
|
if rec := get(h, http.MethodGet, "/panel/x", nil); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("GET /panel/x = %d, want 404", rec.Code)
|
|
}
|
|
if rec := get(h, http.MethodGet, "/bmadmin", nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /bmadmin with admin at /panel = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
var etagForm = regexp.MustCompile(`^"[0-9a-f]{16}"$`)
|
|
|
|
func TestStaticConditionalHeadAndRange(t *testing.T) {
|
|
h := handler(t, "/bmadmin")
|
|
for _, p := range []string{"/", "/plan", "/assets/index-abc.js"} {
|
|
rec := get(h, http.MethodGet, p, nil)
|
|
etag := rec.Header().Get("ETag")
|
|
if !etagForm.MatchString(etag) {
|
|
t.Fatalf("GET %s ETag %q", p, etag)
|
|
}
|
|
rec = get(h, http.MethodGet, p, http.Header{"If-None-Match": {etag}})
|
|
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
|
t.Fatalf("GET %s If-None-Match = %d (%d bytes), want an empty 304", p, rec.Code, rec.Body.Len())
|
|
}
|
|
if rec := get(h, http.MethodGet, p, http.Header{"If-None-Match": {`"0000000000000000"`}}); rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s stale tag = %d, want 200", p, rec.Code)
|
|
}
|
|
}
|
|
if a, b := get(h, http.MethodGet, "/", nil).Header().Get("ETag"), get(h, http.MethodGet, "/assets/index-abc.css", nil).Header().Get("ETag"); a == b {
|
|
t.Fatal("different files share an ETag")
|
|
}
|
|
rec := get(h, http.MethodHead, "/", nil)
|
|
if rec.Code != 200 || rec.Body.Len() != 0 || rec.Header().Get("Content-Length") == "" {
|
|
t.Fatalf("HEAD /: %d, %d body bytes, Content-Length %q", rec.Code, rec.Body.Len(), rec.Header().Get("Content-Length"))
|
|
}
|
|
rec = get(h, http.MethodGet, "/assets/font-abc.woff2", http.Header{"Range": {"bytes=0-1"}})
|
|
if rec.Code != http.StatusPartialContent || rec.Body.String() != "wO" {
|
|
t.Fatalf("Range = %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if got := get(h, http.MethodGet, "/", nil).Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("X-Content-Type-Options = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStaticNeedsIndex(t *testing.T) {
|
|
for name, fsys := range map[string]fstest.MapFS{
|
|
"empty": {},
|
|
"no index": {"assets/a.js": {Data: []byte("x")}},
|
|
} {
|
|
if _, err := newStaticApp(fsys, "/bmadmin"); err == nil || !strings.Contains(err.Error(), "index.html missing") {
|
|
t.Fatalf("%s: err = %v", name, err)
|
|
}
|
|
}
|
|
// A dot directory is skipped whole while loading.
|
|
s, err := newStaticApp(fstest.MapFS{
|
|
"index.html": {Data: []byte("x")}, ".git/config": {Data: []byte("s")}, "a/.env": {Data: []byte("s")}, "a/b.txt": {Data: []byte("ok")},
|
|
}, "/bmadmin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(s.files) != 2 {
|
|
t.Fatalf("loaded %d files, want index.html and a/b.txt", len(s.files))
|
|
}
|
|
}
|
|
|
|
func TestEmbeddedPublicHasIndex(t *testing.T) {
|
|
h, err := newStaticHandler(nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("the embedded public/ is not a built app: %v", err)
|
|
}
|
|
rec := get(h, http.MethodGet, "/plan", nil)
|
|
if rec.Code != 200 || !strings.Contains(strings.ToLower(rec.Body.String()), "<!doctype html") {
|
|
t.Fatalf("GET /plan = %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestStaticThroughRouter assembles the real router: the API keeps its
|
|
// routes and its 401, the reserved paths do not fall back to the index, and
|
|
// the rest does.
|
|
func TestStaticThroughRouter(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 67108864\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
|
|
"SUMMER_ENV=testing", "SUMMER_GOLEM15__USER__JWT__SECRET=jz-bm-test-only-user-jwt-secret-32bytes",
|
|
"SUMMER_ADMIN__JWT__SECRET=jz-bm-test-only-admin-jwt-secret-32bytes",
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
plugins, err := party.Activate(app, []string{"golem15.user", "jz.bm"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pl := range plugins {
|
|
if p, ok := pl.(*Plugin); ok {
|
|
p.publicFS = fixturePublic()
|
|
}
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("Assemble: %v", err)
|
|
}
|
|
cases := []struct {
|
|
method, target string
|
|
status int
|
|
body string
|
|
}{
|
|
{http.MethodGet, "/", 200, indexBody},
|
|
{http.MethodGet, "/lektury", 200, indexBody},
|
|
{http.MethodHead, "/lektury", 200, ""},
|
|
{http.MethodGet, "/assets/index-abc.js", 200, "console.log(1)"},
|
|
{http.MethodGet, "/_bm/api/v1/dashboard", 401, ""},
|
|
{http.MethodGet, "/_bm/api/v1/nope", 404, ""},
|
|
{http.MethodGet, "/_user/api/v1/nope", 404, ""},
|
|
{http.MethodGet, "/_quizzes/api/v1/nope", 404, ""},
|
|
{http.MethodGet, "/storage/app/uploads/public/x.pdf", 404, ""},
|
|
}
|
|
for _, c := range cases {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(c.method, c.target, nil))
|
|
if rec.Code != c.status {
|
|
t.Fatalf("%s %s = %d, want %d (%s)", c.method, c.target, rec.Code, c.status, rec.Body.String())
|
|
}
|
|
if c.body != "" && rec.Body.String() != c.body {
|
|
t.Fatalf("%s %s body %q", c.method, c.target, rec.Body.String())
|
|
}
|
|
if c.status == 404 && strings.Contains(rec.Body.String(), "<div") {
|
|
t.Fatalf("%s %s fell back to the index", c.method, c.target)
|
|
}
|
|
}
|
|
}
|