704 lines
22 KiB
Go
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)
|
|
}
|
|
}
|