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

378 lines
12 KiB
Go

package site
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// depTime is the deployment's creation time, which is the Last-Modified of
// every file it serves.
var depTime = time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
type serveEnv struct {
h *Handler
cs *cas.Store
reg *Registry
p *Project
log *bytes.Buffer
}
// newServeEnv puts contents into a real content store and publishes a
// deployment of them. The store is real because the read path's whole shape —
// open by digest, hand the file to ServeContent — only means anything against
// actual files.
func newServeEnv(t *testing.T, cfg *ProjectConfig, contents map[string]string) *serveEnv {
t.Helper()
base := t.TempDir()
buf := &bytes.Buffer{}
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
cs, err := cas.Open(base+"/cas", cas.Options{ProbeDir: base + "/deployments", Log: log})
if err != nil {
t.Fatalf("cas.Open: %v", err)
}
t.Cleanup(func() { cs.Close() })
rows := make([]store.FileRow, 0, len(contents))
for p, c := range contents {
d := cas.Sum([]byte(c))
limit := int64(len(c))
if limit < 1 {
limit = 1
}
if _, err := cs.Put(t.Context(), d, int64(len(c)), limit, strings.NewReader(c)); err != nil {
t.Fatalf("cas.Put %s: %v", p, err)
}
rows = append(rows, store.FileRow{Path: p, Digest: d, Size: int64(len(c))})
}
sp := NewProject(&store.Project{ID: 1, Name: "demo"})
sp.SetConfig(cfg)
sp.Activate(NewDeployment(
&store.Deployment{PublicID: "dpl_0123456789abcdef", ProjectID: 1, CreatedAt: depTime},
NewIndex(rows), ""))
reg := NewRegistry()
reg.Replace([]*Project{sp})
return &serveEnv{
h: &Handler{Registry: reg, CAS: cs, Log: log},
cs: cs, reg: reg, p: sp, log: buf,
}
}
func (e *serveEnv) do(method, target string, header http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for k, vs := range header {
req.Header[k] = vs
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
func (e *serveEnv) get(target string) *httptest.ResponseRecorder {
return e.do(http.MethodGet, target, nil)
}
func demoSite() map[string]string {
return map[string]string{
"index.html": "<h1>hello</h1>",
"assets/app.js": "console.log(1)",
"docs/index.html": "<p>docs</p>",
"404.html": "<p>gone</p>",
"data.bin": strings.Repeat("x", 4096),
"noext": "plain",
}
}
func TestServeFile(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.get("/~demo/assets/app.js")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Body.String(); got != "console.log(1)" {
t.Errorf("body = %q", got)
}
head := rec.Header()
if ct := head.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") &&
!strings.HasPrefix(ct, "application/javascript") {
t.Errorf("Content-Type = %q, want a javascript type", ct)
}
if want := `"sha256:` + cas.Sum([]byte("console.log(1)")).String() + `"`; head.Get("Etag") != want {
t.Errorf("ETag = %q, want %q", head.Get("Etag"), want)
}
if head.Get("X-Content-Type-Options") != "nosniff" {
t.Error("nosniff is missing")
}
if head.Get("Cache-Control") != plain().CacheControl {
t.Errorf("Cache-Control = %q", head.Get("Cache-Control"))
}
// The deployment's time, never the blob's: a shared blob's mtime says when
// some unrelated project first uploaded the same bytes.
if got := head.Get("Last-Modified"); got != depTime.Format(http.TimeFormat) {
t.Errorf("Last-Modified = %q, want %q", got, depTime.Format(http.TimeFormat))
}
if head.Get("Vary") != "" {
t.Errorf("Vary = %q with the SPA fallback off", head.Get("Vary"))
}
}
func TestServeIndexAndRedirects(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
if rec := e.get("/~demo/"); rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Errorf("root: %d %q", rec.Code, rec.Body.String())
}
if rec := e.get("/~demo"); rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/~demo/" {
t.Errorf("bare name: %d %q", rec.Code, rec.Header().Get("Location"))
}
if rec := e.get("/~demo/docs"); rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/~demo/docs/" {
t.Errorf("directory: %d %q", rec.Code, rec.Header().Get("Location"))
}
// The query survives the redirect, or a link with parameters loses them.
if rec := e.get("/~demo/docs?a=1&b=2"); rec.Header().Get("Location") != "/~demo/docs/?a=1&b=2" {
t.Errorf("query dropped: %q", rec.Header().Get("Location"))
}
if rec := e.get("/~demo/docs/"); rec.Body.String() != "<p>docs</p>" {
t.Errorf("directory index: %q", rec.Body.String())
}
}
// A file name that needs escaping must not arrive raw in the Location header.
func TestServeRedirectEscapesTheLocation(t *testing.T) {
e := newServeEnv(t, plain(), map[string]string{"a b/index.html": "spaced"})
rec := e.do(http.MethodGet, "/~demo/a%20b", nil)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/~demo/a%20b/" {
t.Errorf("Location = %q, want the space escaped", got)
}
}
func TestServeMethodGate(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodHead, "/~demo/index.html", nil)
if rec.Code != http.StatusOK {
t.Errorf("HEAD status = %d", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD returned %d bytes of body", rec.Body.Len())
}
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
rec := e.do(m, "/~demo/index.html", nil)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s status = %d, want 405", m, rec.Code)
}
if got := rec.Header().Get("Allow"); got != "GET, HEAD" {
t.Errorf("%s Allow = %q", m, got)
}
}
}
func TestServeUnknownProjectAndPath(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
for _, target := range []string{"/~nosuch/", "/~nosuch/index.html"} {
if rec := e.get(target); rec.Code != http.StatusNotFound {
t.Errorf("%s = %d, want 404", target, rec.Code)
}
}
for _, target := range []string{"/", "/index.html", "/api/v1/projects", "/~"} {
rec := e.get(target)
if rec.Code != http.StatusNotFound {
t.Errorf("%s = %d, want 404", target, rec.Code)
}
if !strings.Contains(rec.Body.String(), "/~project/") {
t.Errorf("%s: the 404 does not say where sites live: %q", target, rec.Body.String())
}
}
// A project with no deployment exists but has nothing to serve, which is a
// different answer from one that does not exist.
e.reg.Put(&store.Project{ID: 2, Name: "empty"})
if rec := e.get("/~empty/"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("undeployed project = %d, want 503", rec.Code)
}
}
func TestServeConditionalRequest(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
etag := e.get("/~demo/index.html").Header().Get("Etag")
if etag == "" {
t.Fatal("no ETag to revalidate with")
}
rec := e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {etag}})
if rec.Code != http.StatusNotModified {
t.Errorf("status = %d, want 304", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("304 carried %d bytes", rec.Body.Len())
}
// A stale validator must still transfer the content.
rec = e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {`"sha256:stale"`}})
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Errorf("stale validator: %d %q", rec.Code, rec.Body.String())
}
rec = e.do(http.MethodGet, "/~demo/index.html",
http.Header{"If-Modified-Since": {depTime.Add(time.Hour).Format(http.TimeFormat)}})
if rec.Code != http.StatusNotModified {
t.Errorf("If-Modified-Since: status = %d, want 304", rec.Code)
}
}
func TestServeRange(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodGet, "/~demo/data.bin", http.Header{"Range": {"bytes=10-19"}})
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if got := rec.Body.String(); got != strings.Repeat("x", 10) {
t.Errorf("body = %q (%d bytes)", got, len(got))
}
if got := rec.Header().Get("Content-Range"); got != "bytes 10-19/4096" {
t.Errorf("Content-Range = %q", got)
}
if rec.Header().Get("Accept-Ranges") != "bytes" {
t.Error("ranges are not advertised")
}
}
func TestServeCustom404(t *testing.T) {
e := newServeEnv(t, withNotFound(), demoSite())
rec := e.get("/~demo/nope.txt")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if rec.Body.String() != "<p>gone</p>" {
t.Errorf("body = %q, want the project's error document", rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
t.Errorf("Content-Type = %q", got)
}
if got := rec.Header().Get("Content-Length"); got != "11" {
t.Errorf("Content-Length = %q, want 11", got)
}
// An error document is not a cacheable validator target; it must not claim
// to be the requested resource.
if rec.Header().Get("Etag") != "" {
t.Error("the error document was served with an ETag")
}
// HEAD gets the headers and no body.
rec = e.do(http.MethodHead, "/~demo/nope.txt", nil)
if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
t.Errorf("HEAD of a 404: %d, %d bytes", rec.Code, rec.Body.Len())
}
}
func TestServeSPAVariesOnAccept(t *testing.T) {
e := newServeEnv(t, withSPA(), demoSite())
rec := e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"text/html"}})
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
t.Fatalf("navigation: %d %q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Vary") != "Accept" {
t.Error("a response that depends on Accept did not say so")
}
rec = e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"*/*"}})
if rec.Code != http.StatusNotFound {
t.Errorf("fetch: %d, want 404", rec.Code)
}
}
func TestServeUnknownExtensionIsNotSniffed(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.get("/~demo/noext")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", got)
}
}
func TestServeBadRequestPath(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
rec := e.do(http.MethodGet, "/~demo/bad%00path", nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
// The grace period is supposed to make this unreachable, so it means a bug or
// an operator who cleared the store by hand. The request must fail cleanly and
// the reason must reach the log.
func TestServeMissingBlobIs404AndLogged(t *testing.T) {
e := newServeEnv(t, plain(), demoSite())
if err := os.Remove(e.cs.Path(cas.Sum([]byte("<h1>hello</h1>")))); err != nil {
t.Fatal(err)
}
rec := e.get("/~demo/index.html")
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if !strings.Contains(e.log.String(), "blob missing from the content store") {
t.Errorf("nothing was logged about the missing blob: %s", e.log.String())
}
}
// An in-flight response keeps reading the deployment it started on. This is the
// property a symlink swap cannot give, and the reason the switch is a pointer
// store.
func TestServeInFlightResponseSurvivesAnActivation(t *testing.T) {
e := newServeEnv(t, plain(), map[string]string{"index.html": "v1"})
// Open the blob the way the handler does, then activate over the top of it.
first := e.p.Active()
entry, ok := first.Lookup("index.html")
if !ok {
t.Fatal("no index.html in the first deployment")
}
f, err := e.cs.Open(entry.Digest)
if err != nil {
t.Fatal(err)
}
defer f.Close()
d2 := cas.Sum([]byte("v2"))
if _, err := e.cs.Put(t.Context(), d2, 2, 2, strings.NewReader("v2")); err != nil {
t.Fatal(err)
}
e.p.Activate(NewDeployment(
&store.Deployment{PublicID: "dpl_second", ProjectID: 1, CreatedAt: depTime},
NewIndex([]store.FileRow{{Path: "index.html", Digest: d2, Size: 2}}), ""))
buf := make([]byte, 8)
n, _ := f.Read(buf)
if string(buf[:n]) != "v1" {
t.Errorf("the open file returned %q, want the deployment it was opened on", buf[:n])
}
if rec := e.get("/~demo/"); rec.Body.String() != "v2" {
t.Errorf("a new request got %q, want v2", rec.Body.String())
}
}