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