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

hello

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

hello

", // 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("

hello

") + 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 }