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

469 lines
15 KiB
Go

package deploy
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// env is a service on a real database and a real CAS, which is what these tests
// are about: every interesting rule here is enforced by a trigger, a unique
// index or the filesystem, and a fake would only assert that the fake agrees
// with itself.
type env struct {
svc *Service
db *store.DB
cas *cas.Store
p *store.Project
dir string // deployments root
}
func newEnv(t *testing.T) *env {
t.Helper()
base := t.TempDir()
log := slog.New(slog.DiscardHandler)
db, err := store.Open(t.Context(), filepath.Join(base, "pages.db"), log)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
deployDir := filepath.Join(base, "deployments")
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir, Log: log})
if err != nil {
t.Fatalf("cas.Open: %v", err)
}
t.Cleanup(func() { cs.Close() })
p := store.DefaultProject("demo")
if err := db.CreateProject(t.Context(), p); err != nil {
t.Fatalf("CreateProject: %v", err)
}
return &env{
svc: &Service{DB: db, CAS: cs, Log: log, Dir: deployDir},
db: db, cas: cs, p: p, dir: deployDir,
}
}
func (e *env) create(t *testing.T) *store.Deployment {
t.Helper()
dep, err := e.svc.Create(t.Context(), e.p, "", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
return dep
}
// manifest turns path->content into the rows a client would have sent.
func manifest(contents map[string]string) []store.FileRow {
files := make([]store.FileRow, 0, len(contents))
for p, c := range contents {
files = append(files, store.FileRow{Path: p, Digest: cas.Sum([]byte(c)), Size: int64(len(c))})
}
return files
}
// upload pushes every named blob through the service, as a client would.
func (e *env) upload(t *testing.T, contents map[string]string, want ...string) {
t.Helper()
for _, p := range want {
c := contents[p]
if _, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte(c)), strings.NewReader(c)); err != nil {
t.Fatalf("Upload %s: %v", p, err)
}
}
}
// apiCode returns the wire code of err, or "" if it is not a client error.
func apiCode(err error) api.Code {
var e *api.Error
if errors.As(err, &e) {
return e.Code
}
return ""
}
func wantCode(t *testing.T, err error, want api.Code) {
t.Helper()
if got := apiCode(err); got != want {
t.Fatalf("error = %v (code %q), want code %q", err, got, want)
}
}
func TestFullDeploymentRoundTrip(t *testing.T) {
e := newEnv(t)
contents := map[string]string{
"index.html": "<h1>hello</h1>",
"assets/app.js": "console.log(1)",
"copy.html": "<h1>hello</h1>", // same blob as index.html
}
dep := e.create(t)
if dep.State != store.StatePending {
t.Fatalf("state = %s, want pending", dep.State)
}
files := manifest(contents)
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep, files)
if err != nil {
t.Fatalf("SetManifest: %v", err)
}
// Two unique blobs for three files: the shared one is only asked for once.
if len(missing) != 2 {
t.Fatalf("missing = %d digests, want 2", len(missing))
}
if want := int64(len("<h1>hello</h1>") + len("console.log(1)")); missingBytes != want {
t.Errorf("missingBytes = %d, want %d", missingBytes, want)
}
// Finalize before the content arrives names what is still outstanding rather
// than failing opaquely, because that list is what the client retries.
_, err = e.svc.Finalize(t.Context(), e.p, dep)
wantCode(t, err, api.CodeBlobsMissing)
var apiErr *api.Error
if errors.As(err, &apiErr) {
list, _ := apiErr.Details["missing"].([]string)
if len(list) != 2 {
t.Errorf("details.missing = %v, want 2 digests", apiErr.Details["missing"])
}
}
e.upload(t, contents, "index.html", "assets/app.js")
dep, err = e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if dep.State != store.StateReady {
t.Fatalf("state = %s, want ready", dep.State)
}
if dep.FileCount != 3 || dep.TotalBytes != int64(len(contents["index.html"])*2+len(contents["assets/app.js"])) {
t.Errorf("file_count = %d, total_bytes = %d", dep.FileCount, dep.TotalBytes)
}
if dep.FinalizedAt == nil {
t.Error("finalized_at was not recorded")
}
if dep.Active {
t.Error("finalize activated the deployment; that is a separate decision")
}
// The tree on disk is the deployment, byte for byte.
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
if len(got) != len(contents) {
t.Fatalf("assembled %v", got)
}
for p, c := range contents {
if got[p] != c {
t.Errorf("%s = %q, want %q", p, got[p], c)
}
}
}
// A second deployment of a mostly-unchanged site is the case the whole
// content-addressed protocol exists for: only what actually changed is asked
// for, across deployments and across projects.
func TestASecondDeploymentOnlyAsksForWhatChanged(t *testing.T) {
e := newEnv(t)
first := map[string]string{"index.html": "v1", "assets/app.js": "shared"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(first)); err != nil {
t.Fatal(err)
}
e.upload(t, first, "index.html", "assets/app.js")
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
t.Fatal(err)
}
second := map[string]string{"index.html": "v2", "assets/app.js": "shared"}
dep2 := e.create(t)
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep2, manifest(second))
if err != nil {
t.Fatal(err)
}
if len(missing) != 1 || missing[0] != cas.Sum([]byte("v2")) {
t.Fatalf("missing = %v, want just the changed index.html", digests(missing))
}
if missingBytes != 2 {
t.Errorf("missingBytes = %d, want 2", missingBytes)
}
e.upload(t, second, "index.html")
if _, err := e.svc.Finalize(t.Context(), e.p, dep2); err != nil {
t.Fatal(err)
}
// Both trees exist and disagree, which is what makes a rollback a rollback.
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID)); got["index.html"] != "v1" {
t.Errorf("first deployment = %v, want v1 intact", got)
}
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep2.PublicID)); got["index.html"] != "v2" {
t.Errorf("second deployment = %v", got)
}
}
func digests(ds []cas.Digest) []string {
out := make([]string, len(ds))
for i, d := range ds {
out[i] = d.String()
}
return out
}
// The upload endpoint is not general-purpose storage. Content nobody declared
// has no size the server agreed to and no deployment that would ever reference
// it, so it is refused before a byte is read.
func TestUploadRejectsContentNoManifestAskedFor(t *testing.T) {
e := newEnv(t)
body := &countingReader{r: strings.NewReader("unsolicited")}
_, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte("unsolicited")), body)
wantCode(t, err, api.CodeNotFound)
if body.n != 0 {
t.Errorf("read %d bytes of a body it had already decided to refuse", body.n)
}
if has, _ := e.cas.Has(cas.Sum([]byte("unsolicited"))); has {
t.Error("the content was stored anyway")
}
}
// The claimed digest is only ever a claim. Without this check a client could
// declare another project's digest and poison every project sharing that blob.
func TestUploadRejectsContentThatDoesNotHashToItsDigest(t *testing.T) {
e := newEnv(t)
contents := map[string]string{"index.html": "honest"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
claimed := cas.Sum([]byte("honest"))
_, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("forged"))
wantCode(t, err, api.CodeDigestMismatch)
if has, _ := e.cas.Has(claimed); has {
t.Fatal("the forged content was stored under the honest digest")
}
// And the deployment is still deployable once the real bytes arrive.
if _, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("honest")); err != nil {
t.Fatalf("honest upload after a forged one: %v", err)
}
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
t.Fatalf("Finalize: %v", err)
}
}
// The manifest's size is the ceiling, so a client cannot spend more disk than
// the manifest it got accepted for. Content-Length is never consulted.
func TestUploadRejectsMoreBytesThanTheManifestDeclared(t *testing.T) {
e := newEnv(t)
dep := e.create(t)
body := strings.Repeat("x", 4096)
// Declare a small file, then send a large one under the same digest.
files := []store.FileRow{{Path: "a.txt", Digest: cas.Sum([]byte(body)), Size: 4}}
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
t.Fatal(err)
}
_, _, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(body))
if code := apiCode(err); code != api.CodeSizeMismatch && code != api.CodeLimitExceeded {
t.Fatalf("error = %v (code %q), want a size rejection", err, code)
}
if has, _ := e.cas.Has(files[0].Digest); has {
t.Error("the oversized content was stored")
}
}
// Re-uploading a blob the server already has is the fast path a retried deploy
// depends on: no body is read and nothing is rewritten.
func TestUploadOfAPresentBlobDoesNotReadTheBody(t *testing.T) {
e := newEnv(t)
contents := map[string]string{"index.html": "hello"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
d := cas.Sum([]byte("hello"))
size, stored, err := e.svc.Upload(t.Context(), d, strings.NewReader("hello"))
if err != nil || !stored || size != 5 {
t.Fatalf("first upload: size=%d stored=%v err=%v", size, stored, err)
}
body := &countingReader{r: strings.NewReader("hello")}
size, stored, err = e.svc.Upload(t.Context(), d, body)
if err != nil {
t.Fatal(err)
}
if stored {
t.Error("the second upload claimed to have stored content the server already had")
}
if size != 5 {
t.Errorf("size = %d, want 5", size)
}
if body.n != 0 {
t.Errorf("read %d bytes of a blob it already had", body.n)
}
}
func TestUploadStoresAnEmptyBlob(t *testing.T) {
e := newEnv(t)
dep := e.create(t)
files := []store.FileRow{{Path: "empty", Digest: cas.Sum(nil), Size: 0}}
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
t.Fatal(err)
}
// Put insists on a positive ceiling; the service floors it at one byte so a
// legitimately empty file is still uploadable.
size, stored, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(""))
if err != nil || !stored || size != 0 {
t.Fatalf("size=%d stored=%v err=%v", size, stored, err)
}
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
t.Fatalf("Finalize: %v", err)
}
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
if c, ok := got["empty"]; !ok || c != "" {
t.Errorf("tree = %v, want one empty file", got)
}
}
// Finalizing twice must be a no-op rather than a second assembly: the client
// that lost its response to a timeout retries, and the tree may already be live.
func TestFinalizeIsIdempotent(t *testing.T) {
e := newEnv(t)
contents := map[string]string{"index.html": "hello"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
e.upload(t, contents, "index.html")
first, err := e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatal(err)
}
second, err := e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("second Finalize: %v", err)
}
if !first.FinalizedAt.Equal(*second.FinalizedAt) {
t.Errorf("finalized_at moved from %v to %v on a retry", first.FinalizedAt, second.FinalizedAt)
}
}
func TestFinalizeRejectsADeploymentWithNoManifest(t *testing.T) {
e := newEnv(t)
dep := e.create(t) // still pending
_, err := e.svc.Finalize(t.Context(), e.p, dep)
wantCode(t, err, api.CodeConflict)
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
t.Error("a tree was built for a deployment that never had a manifest")
}
}
// A deployment belongs to exactly one project. Finalizing another project's
// deployment must not work even when the caller knows its id.
func TestFinalizeIsProjectScoped(t *testing.T) {
e := newEnv(t)
other := store.DefaultProject("other")
if err := e.db.CreateProject(t.Context(), other); err != nil {
t.Fatal(err)
}
contents := map[string]string{"index.html": "hello"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
e.upload(t, contents, "index.html")
_, err := e.svc.Finalize(t.Context(), other, dep)
wantCode(t, err, api.CodeNotFound)
}
// assemble_mode=none: content is served straight from the CAS, so finalize must
// still succeed and must not build anything on disk.
func TestFinalizeWithoutAssemblyBuildsNothing(t *testing.T) {
e := newEnv(t)
e.svc.Dir = ""
contents := map[string]string{"index.html": "hello"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
e.upload(t, contents, "index.html")
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if dep.State != store.StateReady {
t.Fatalf("state = %s, want ready", dep.State)
}
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
t.Error("a tree was assembled despite assemble_mode=none")
}
}
// If assembly fails the deployment must end up failed rather than ready — a
// ready deployment is one that could be served, and this one could not be.
func TestFinalizeMarksTheDeploymentFailedWhenAssemblyCannotProceed(t *testing.T) {
e := newEnv(t)
contents := map[string]string{"index.html": "hello"}
dep := e.create(t)
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
t.Fatal(err)
}
e.upload(t, contents, "index.html")
// A plain file where the project's directory has to go: MkdirAll cannot get
// past it, so assembly fails for a reason that is not the client's fault.
if err := os.MkdirAll(e.dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Dir(DeploymentDir(e.dir, e.p.ID, dep.PublicID)), []byte("in the way"), 0o644); err != nil {
t.Fatal(err)
}
_, err := e.svc.Finalize(t.Context(), e.p, dep)
wantCode(t, err, api.CodeInternal)
// The reason stays server-side: the client is told nothing about the layout
// of the server's disk.
if strings.Contains(err.Error(), e.dir) {
t.Errorf("the error exposes a server path: %v", err)
}
after, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
if err != nil {
t.Fatal(err)
}
if after.State != store.StateFailed {
t.Fatalf("state = %s, want failed", after.State)
}
if after.Error == "" {
t.Error("no reason was recorded for the failure")
}
// And it stays failed: a failed deployment is never resurrected.
_, err = e.svc.Finalize(t.Context(), e.p, dep)
wantCode(t, err, api.CodeConflict)
}
// countingReader reports whether a body was read at all, which is how the tests
// tell "refused up front" apart from "read and then discarded".
type countingReader struct {
r io.Reader
n int
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}