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": "

hello

", "assets/app.js": "console.log(1)", "docs/index.html": "

docs

", "404.html": "

gone

", "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() != "

hello

" { 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() != "

docs

" { 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() != "

hello

" { 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() != "

gone

" { 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() != "

hello

" { 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("

hello

")))); 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()) } }