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

704 lines
22 KiB
Go

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", "<h1>hi</h1>"),
file("assets/app.js", "console.log(1)"),
file("copy.html", "<h1>hi</h1>"), // 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)
}
}