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