package store import ( "context" "errors" "testing" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/cas" ) // The lifecycle is spelled out in three places that cannot import each other: // this package, the CHECK constraint in the schema, and api for the wire. // Renaming a state in one of them without the others would surface as a // constraint violation in production rather than at compile time, so it is // asserted here instead. func TestStateConstantsMatchTheWire(t *testing.T) { states := []struct { store State wire string }{ {StatePending, api.StatePending}, {StateUploading, api.StateUploading}, {StateReady, api.StateReady}, {StateFailed, api.StateFailed}, {StateDeleting, api.StateDeleting}, } db := testDB(t) p := testProject(t, db, "demo") for _, s := range states { if string(s.store) != s.wire { t.Errorf("store %q and wire %q disagree", s.store, s.wire) } // And the schema accepts it: a state this package can set but the CHECK // constraint rejects would only fail once something reached that state. dep := testDeployment(t, db, p.ID) if _, err := db.w.ExecContext(context.Background(), `UPDATE deployments SET state = ? WHERE id = ?`, s.store, dep.ID); err != nil { t.Errorf("the schema rejects state %q: %v", s.store, err) } } } func testProject(t *testing.T, db *DB, name string) *Project { t.Helper() p := DefaultProject(name) if err := db.CreateProject(context.Background(), p); err != nil { t.Fatalf("CreateProject(%q): %v", name, err) } return p } func testDeployment(t *testing.T, db *DB, projectID int64) *Deployment { t.Helper() dep := &Deployment{ProjectID: projectID} if err := db.CreateDeployment(context.Background(), dep); err != nil { t.Fatalf("CreateDeployment: %v", err) } return dep } // file builds a manifest entry whose digest really is the digest of content, so // tests never accidentally assert on an impossible pairing. func file(path, content string) FileRow { return FileRow{Path: path, Digest: cas.Sum([]byte(content)), Size: int64(len(content))} } func refcount(t *testing.T, db *DB, d cas.Digest) int { t.Helper() var n int if err := db.Reader().QueryRow(`SELECT refcount FROM blobs WHERE digest = ?`, d.Bytes()).Scan(&n); err != nil { t.Fatalf("refcount(%s): %v", d, err) } return n } func digests(ds []cas.Digest) []string { out := make([]string, len(ds)) for i, d := range ds { out[i] = d.String() } return out } func TestCreateDeployment(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := &Deployment{ProjectID: p.ID, CreatedByKey: "k7m2qabcdefghijk", Meta: map[string]string{"git_sha": "abc"}} // The key must exist: created_by_key is a foreign key. if _, err := db.w.ExecContext(ctx, `INSERT INTO api_keys (id, secret_hash, scope, project_id, created_at) VALUES (?, x'00', 'project', ?, 1)`, dep.CreatedByKey, p.ID); err != nil { t.Fatal(err) } if err := db.CreateDeployment(ctx, dep); err != nil { t.Fatal(err) } if dep.ID == 0 || dep.PublicID == "" || dep.State != StatePending { t.Fatalf("CreateDeployment left %+v", dep) } got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID) if err != nil { t.Fatal(err) } if got.Meta["git_sha"] != "abc" { t.Errorf("meta = %v, want git_sha=abc", got.Meta) } if got.CreatedByKey != dep.CreatedByKey || got.Active || got.CreatedAt.IsZero() { t.Errorf("round-tripped as %+v", got) } } func TestNewDeploymentIDsAreDistinct(t *testing.T) { seen := make(map[string]bool, 256) for range 256 { id, err := NewDeploymentID() if err != nil { t.Fatal(err) } if len(id) != len("dpl_")+16 { t.Fatalf("id %q has the wrong shape", id) } if seen[id] { t.Fatalf("NewDeploymentID repeated %q", id) } seen[id] = true } } // Security requirement: a deployment id is only ever resolvable inside its own // project, so a project-scoped caller cannot name a neighbour's deployment even // knowing its id exactly. func TestDeploymentByPublicIDIsProjectScoped(t *testing.T) { ctx := context.Background() db := testDB(t) mine := testProject(t, db, "mine") theirs := testProject(t, db, "theirs") dep := testDeployment(t, db, theirs.ID) if _, err := db.DeploymentByPublicID(ctx, theirs.ID, dep.PublicID); err != nil { t.Fatalf("the owning project cannot see its own deployment: %v", err) } _, err := db.DeploymentByPublicID(ctx, mine.ID, dep.PublicID) if !errors.Is(err, ErrNotFound) { t.Errorf("cross-project lookup = %v, want ErrNotFound", err) } } // The ordering claim the upload protocol rests on: once SetManifest commits, // every blob the deployment needs is already refcounted, so nothing GC does // while the content is still being uploaded can take one away. func TestSetManifestRefcountsBeforeUpload(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) files := []FileRow{ file("index.html", "

hi

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

hi

"), // same content as index.html } missing, missingBytes, err := db.SetManifest(ctx, dep.ID, files) if err != nil { t.Fatal(err) } // Two distinct digests, each reported once, in first-appearance order. want := []string{files[0].Digest.String(), files[1].Digest.String()} if got := digests(missing); len(got) != 2 || got[0] != want[0] || got[1] != want[1] { t.Errorf("missing = %v, want %v", got, want) } if wantBytes := files[0].Size + files[1].Size; missingBytes != wantBytes { t.Errorf("missingBytes = %d, want %d", missingBytes, wantBytes) } if got := refcount(t, db, files[0].Digest); got != 2 { t.Errorf("shared blob refcount = %d, want 2 (two paths reference it)", got) } if got := refcount(t, db, files[1].Digest); got != 1 { t.Errorf("refcount = %d, want 1", got) } got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID) if err != nil { t.Fatal(err) } if got.State != StateUploading { t.Errorf("state = %s, want uploading", got.State) } if got.FileCount != 3 { t.Errorf("file_count = %d, want 3", got.FileCount) } if wantTotal := files[0].Size + files[1].Size + files[2].Size; got.TotalBytes != wantTotal { t.Errorf("total_bytes = %d, want %d", got.TotalBytes, wantTotal) } } // A CLI that lost its connection mid-negotiation re-sends the manifest. The // second one must replace the first outright, refcounts included. func TestSetManifestReplacesTheEarlierAttempt(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) dropped := file("old.html", "version one") kept := file("index.html", "shared") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{dropped, kept}); err != nil { t.Fatal(err) } added := file("assets/app.js", "version two") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept, added}); err != nil { t.Fatal(err) } if got := refcount(t, db, dropped.Digest); got != 0 { t.Errorf("dropped blob refcount = %d, want 0", got) } if got := refcount(t, db, kept.Digest); got != 1 { t.Errorf("kept blob refcount = %d, want 1", got) } if got := refcount(t, db, added.Digest); got != 1 { t.Errorf("added blob refcount = %d, want 1", got) } files, err := db.DeploymentFiles(ctx, dep.ID) if err != nil { t.Fatal(err) } if len(files) != 2 || files[0].Path != "assets/app.js" || files[1].Path != "index.html" { t.Errorf("manifest = %+v, want the second attempt ordered by path", files) } } func TestSetManifestRejectsASizeDisagreement(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") f := file("index.html", "content") first := testDeployment(t, db, p.ID) if _, _, err := db.SetManifest(ctx, first.ID, []FileRow{f}); err != nil { t.Fatal(err) } // Same digest, a different declared length: one of the two numbers is a lie // and the manifest cannot be accepted either way. lying := f lying.Size = f.Size + 1 second := testDeployment(t, db, p.ID) _, _, err := db.SetManifest(ctx, second.ID, []FileRow{lying}) if !errors.Is(err, cas.ErrSizeMismatch) { t.Errorf("err = %v, want ErrSizeMismatch", err) } // The same disagreement inside one manifest is caught before any write. third := testDeployment(t, db, p.ID) _, _, err = db.SetManifest(ctx, third.ID, []FileRow{f, {Path: "other.html", Digest: f.Digest, Size: 99}}) if !errors.Is(err, cas.ErrSizeMismatch) { t.Errorf("err = %v, want ErrSizeMismatch", err) } if got := refcount(t, db, f.Digest); got != 1 { t.Errorf("refcount = %d after two rejected manifests, want 1", got) } } func TestSetManifestRejectsAFinishedDeployment(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) f := file("index.html", "content") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil { t.Fatal(err) } if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil { t.Fatal(err) } if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil { t.Fatal(err) } _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("other.html", "x")}) if !errors.Is(err, ErrConflict) { t.Errorf("err = %v, want ErrConflict", err) } } func TestSetManifestOnAMissingDeployment(t *testing.T) { _, _, err := testDB(t).SetManifest(context.Background(), 424242, []FileRow{file("a", "b")}) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } } func TestMissingBlobsShrinksAsContentArrives(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) a, b := file("a.html", "aaa"), file("b.html", "bbb") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{a, b}); err != nil { t.Fatal(err) } if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 2 { t.Fatalf("MissingBlobs = %v, %v; want 2 digests", digests(got), err) } if err := db.MarkBlobPresent(ctx, a.Digest, a.Size); err != nil { t.Fatal(err) } got, err := db.MissingBlobs(ctx, dep.ID) if err != nil { t.Fatal(err) } if len(got) != 1 || got[0] != b.Digest { t.Fatalf("MissingBlobs = %v, want just %s", digests(got), b.Digest) } // A second deployment of overlapping content sees only what is genuinely // new — this is the deduplication the CLI reports as its headline number. next := testDeployment(t, db, p.ID) c := file("c.html", "ccc") missing, _, err := db.SetManifest(ctx, next.ID, []FileRow{a, c}) if err != nil { t.Fatal(err) } if len(missing) != 1 || missing[0] != c.Digest { t.Errorf("missing = %v, want just the new blob", digests(missing)) } if err := db.MarkBlobPresent(ctx, b.Digest, b.Size); err != nil { t.Fatal(err) } if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 0 { t.Errorf("MissingBlobs = %v, %v; want none", digests(got), err) } } func TestMarkBlobPresent(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) f := file("index.html", "content") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil { t.Fatal(err) } if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil { t.Fatal(err) } // Idempotent: a retried upload of a blob that arrived meanwhile is a no-op. if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil { t.Errorf("second MarkBlobPresent: %v", err) } b, err := db.Blob(ctx, f.Digest) if err != nil { t.Fatal(err) } if !b.Present || b.Size != f.Size { t.Errorf("blob = %+v", b) } if err := db.MarkBlobPresent(ctx, f.Digest, f.Size+1); !errors.Is(err, cas.ErrSizeMismatch) { t.Errorf("err = %v, want ErrSizeMismatch", err) } // Unknown digests are refused, which is what stops the upload endpoint from // being used as arbitrary storage. if _, err := db.Blob(ctx, cas.Sum([]byte("never declared"))); !errors.Is(err, ErrNotFound) { t.Errorf("Blob = %v, want ErrNotFound", err) } if err := db.MarkBlobPresent(ctx, cas.Sum([]byte("never declared")), 1); !errors.Is(err, ErrNotFound) { t.Errorf("MarkBlobPresent = %v, want ErrNotFound", err) } } func TestMarkDeploymentReadyIsIdempotent(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) // A pending deployment has no manifest, so there is nothing to finalize. if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) { t.Errorf("finalize while pending = %v, want ErrConflict", err) } f := file("index.html", "content") if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil { t.Fatal(err) } if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil { t.Fatal(err) } first, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID) if err != nil { t.Fatal(err) } if first.State != StateReady || first.FinalizedAt == nil { t.Fatalf("deployment = %+v", first) } if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil { t.Errorf("retried finalize: %v", err) } again, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID) if err != nil { t.Fatal(err) } if !again.FinalizedAt.Equal(*first.FinalizedAt) { t.Errorf("finalized_at moved from %v to %v", first.FinalizedAt, again.FinalizedAt) } } func TestMarkDeploymentFailed(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := testDeployment(t, db, p.ID) if err := db.MarkDeploymentFailed(ctx, dep.ID, "upload timed out"); err != nil { t.Fatal(err) } got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID) if err != nil { t.Fatal(err) } if got.State != StateFailed || got.Error != "upload timed out" { t.Errorf("deployment = %+v", got) } // A failed deployment cannot be resurrected by a late manifest or finalize. if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("a", "b")}); !errors.Is(err, ErrConflict) { t.Errorf("SetManifest = %v, want ErrConflict", err) } if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) { t.Errorf("MarkDeploymentReady = %v, want ErrConflict", err) } } func TestListDeployments(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") other := testProject(t, db, "other") testDeployment(t, db, other.ID) var ids []string for range 5 { ids = append(ids, testDeployment(t, db, p.ID).PublicID) } // Newest first, so reverse creation order. var want []string for i := len(ids) - 1; i >= 0; i-- { want = append(want, ids[i]) } var seen []string cursor := "" for { page, next, err := db.ListDeployments(ctx, p.ID, "", 2, cursor) if err != nil { t.Fatal(err) } for _, d := range page { seen = append(seen, d.PublicID) } if next == "" { break } cursor = next } if len(seen) != len(want) { t.Fatalf("paged %v, want %v", seen, want) } for i := range want { if seen[i] != want[i] { t.Fatalf("paged %v, want %v", seen, want) } } // Filtering by state. fifth, err := db.DeploymentByPublicID(ctx, p.ID, ids[4]) if err != nil { t.Fatal(err) } if err := db.MarkDeploymentFailed(ctx, fifth.ID, "abandoned"); err != nil { t.Fatal(err) } failed, _, err := db.ListDeployments(ctx, p.ID, StateFailed, 10, "") if err != nil { t.Fatal(err) } if len(failed) != 1 || failed[0].PublicID != ids[4] { t.Errorf("failed page = %v, want just %s", failed, ids[4]) } // A cursor GC removed between pages yields an empty page, not an error. gone, _, err := db.ListDeployments(ctx, p.ID, "", 10, "dpl_ffffffffffffffff") if err != nil { t.Fatalf("stale cursor: %v", err) } if len(gone) != 0 { t.Errorf("stale cursor returned %d rows", len(gone)) } } // ready builds a deployment that has a manifest and has been finalized, which // is the only state activation accepts. func ready(t *testing.T, db *DB, projectID int64, content string) *Deployment { t.Helper() ctx := context.Background() dep := testDeployment(t, db, projectID) if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("index.html", content)}); err != nil { t.Fatalf("SetManifest: %v", err) } if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil { t.Fatalf("MarkDeploymentReady: %v", err) } return dep } func TestActivateDeployment(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("ActiveDeployment on a fresh project = %v, want ErrNotFound", err) } first := ready(t, db, p.ID, "v1") if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil { t.Fatal(err) } got, err := db.ActiveDeployment(ctx, p.ID) if err != nil { t.Fatal(err) } if got.ID != first.ID || !got.Active || got.ActivatedAt == nil || got.DeactivatedAt != nil { t.Fatalf("after activation the row is %+v", got) } second := ready(t, db, p.ID, "v2") if err := db.ActivateDeployment(ctx, p.ID, second.ID); err != nil { t.Fatal(err) } got, err = db.ActiveDeployment(ctx, p.ID) if err != nil { t.Fatal(err) } if got.ID != second.ID { t.Fatalf("active = %d, want the second deployment %d", got.ID, second.ID) } // The superseded one stays ready and on disk — that is what makes rollback // a single activation rather than a redeploy — but it is stamped so GC's // grace period can start counting. old, err := db.DeploymentByPublicID(ctx, p.ID, first.PublicID) if err != nil { t.Fatal(err) } if old.Active || old.State != StateReady || old.DeactivatedAt == nil { t.Errorf("the superseded deployment is %+v", old) } if old.ActivatedAt == nil { t.Error("deactivation cleared activated_at") } // Rollback. if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil { t.Fatal(err) } got, err = db.ActiveDeployment(ctx, p.ID) if err != nil { t.Fatal(err) } if got.ID != first.ID || got.DeactivatedAt != nil { t.Errorf("after rollback the active row is %+v", got) } } // Re-activating what is already active must not leave the project with nothing // active in between, which is why the demotion excludes the target row. func TestActivateDeploymentIsIdempotent(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") dep := ready(t, db, p.ID, "v1") for range 3 { if err := db.ActivateDeployment(ctx, p.ID, dep.ID); err != nil { t.Fatal(err) } got, err := db.ActiveDeployment(ctx, p.ID) if err != nil { t.Fatalf("nothing is active after re-activating: %v", err) } if got.ID != dep.ID || got.DeactivatedAt != nil { t.Fatalf("row = %+v", got) } } } func TestActivateDeploymentRequiresReady(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") pending := testDeployment(t, db, p.ID) if err := db.ActivateDeployment(ctx, p.ID, pending.ID); !errors.Is(err, ErrConflict) { t.Errorf("activating a pending deployment = %v, want ErrConflict", err) } failed := testDeployment(t, db, p.ID) if err := db.MarkDeploymentFailed(ctx, failed.ID, "assembly failed"); err != nil { t.Fatal(err) } if err := db.ActivateDeployment(ctx, p.ID, failed.ID); !errors.Is(err, ErrConflict) { t.Errorf("activating a failed deployment = %v, want ErrConflict", err) } if err := db.ActivateDeployment(ctx, p.ID, 9999); !errors.Is(err, ErrNotFound) { t.Errorf("activating a deployment that does not exist = %v, want ErrNotFound", err) } if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) { t.Error("a refused activation left something active") } } // The ownership check the API's authorization rests on lives here too: naming // another project's deployment id must not activate it, and must not disturb // either project. func TestActivateDeploymentIsProjectScoped(t *testing.T) { ctx := context.Background() db := testDB(t) victim := testProject(t, db, "victim") attacker := testProject(t, db, "attacker") target := ready(t, db, victim.ID, "secret") if err := db.ActivateDeployment(ctx, victim.ID, target.ID); err != nil { t.Fatal(err) } mine := ready(t, db, attacker.ID, "mine") if err := db.ActivateDeployment(ctx, attacker.ID, mine.ID); err != nil { t.Fatal(err) } if err := db.ActivateDeployment(ctx, attacker.ID, target.ID); err == nil { t.Fatal("a project activated another project's deployment") } got, err := db.ActiveDeployment(ctx, attacker.ID) if err != nil { t.Fatal(err) } if got.ID != mine.ID { t.Errorf("the cross-project attempt changed the attacker's active deployment to %d", got.ID) } got, err = db.ActiveDeployment(ctx, victim.ID) if err != nil { t.Fatal(err) } if got.ID != target.ID || !got.Active { t.Errorf("the cross-project attempt disturbed the victim: %+v", got) } } // "At most one active deployment per project" is enforced by the database, not // by the code above it. Writing the second active row by hand is the only way // to check that: if this ever stops failing, every guarantee that rests on the // invariant has quietly lost its foundation. func TestOneActiveDeploymentPerProjectIsEnforcedBySchema(t *testing.T) { ctx := context.Background() db := testDB(t) p := testProject(t, db, "demo") other := testProject(t, db, "other") first := ready(t, db, p.ID, "v1") second := ready(t, db, p.ID, "v2") if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil { t.Fatal(err) } if _, err := db.w.ExecContext(ctx, `UPDATE deployments SET active = 1 WHERE id = ?`, second.ID); err == nil { t.Fatal("the schema allowed a project to have two active deployments") } // The index is partial, so it constrains only active rows: any number of // inactive ones per project, and one active row per *other* project. elsewhere := ready(t, db, other.ID, "v1") if err := db.ActivateDeployment(ctx, other.ID, elsewhere.ID); err != nil { t.Fatalf("the index leaked across projects: %v", err) } }