feat: auto-purge — trashed 30 days becomes destroyed, guards fail toward keeping data
release.PurgeApp is the platform's first destructive automation surface, so every guard fails toward keeping data: no valid marker (re-read immediately before destruction, so a late restore wins) refuses with ErrNotTrashed; no release public key refuses, because an app whose production flag cannot be verified cannot be proven safe to destroy; a production flag that is ON — or merely UNREADABLE — refuses. Flag ABSENT purges, since most apps never had one. The marker is deleted LAST, so a purge that dies half-way leaves a trashed app with fewer objects rather than a live app with holes. The reconcile pass consumes the trashed map it already builds: past TrashCooloff, purge; the marker's own deleted_at is the clock. nil ReleasePub leaves the app hidden but intact, which is the reversible half of deletion — the agent arms purging from CARLOS_RELEASE_PUBKEY and says so when it cannot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+513
−0
internal/domains/reconcile.go+26 −0internal/domains/reconcile_test.go+167 −0internal/host/agent.go+11 −0internal/host/host_test.go+40 −0internal/release/trash.go+66 −0internal/release/trash_test.go+203 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index cda5ff5d9..4a35c4c2f 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -2,6 +2,7 @@ package domains |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "crypto/x509" |
| "encoding/json" |
| "encoding/pem" |
| @@ -37,6 +38,11 @@ type Config struct { |
| AcmeDir string |
| // Now is the clock seam; nil = time.Now. |
| Now func() time.Time |
| + // ReleasePub verifies the production flag before the auto-purge pass |
| + // destroys anything. nil disables purging entirely — a box that cannot |
| + // verify must not destroy — and the app simply stays hidden, which is |
| + // the reversible half of deletion and costs nothing to leave in place. |
| + ReleasePub ed25519.PublicKey |
| } |
| // Reconcile runs the spec §3 steps in order, idempotently: bucket account |
| @@ -222,6 +228,26 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| routes = keptLive |
| } |
| + // --- 1d. auto-purge ------------------------------------------------------ |
| + // Trashed longer than TrashCooloff → destroy. The marker's own deleted_at |
| + // is the clock (bucket-side; restore deletes the marker, so there is |
| + // nothing to replay). PurgeApp RE-READS the marker immediately before |
| + // destroying — this listing is only a hint — and re-checks the production |
| + // flag; both guards fail toward keeping data. Idempotent and race-proof: |
| + // concurrent boxes converge because every step tolerates already-gone and |
| + // the marker goes last. |
| + for k, tomb := range trashed { |
| + if cfg.Now().Sub(tomb.DeletedAt) < release.TrashCooloff { |
| + continue |
| + } |
| + if err := release.PurgeApp(ctx, s, cfg.ReleasePub, k.acct, k.app, cfg.Now()); err != nil { |
| + log.Printf("domains: auto-purge %s/%s: %v", k.acct, k.app, err) |
| + continue |
| + } |
| + log.Printf("domains: purged %s/%s — trashed %s, past the %s cooloff", |
| + k.acct, k.app, tomb.DeletedAt.Format(time.RFC3339), release.TrashCooloff) |
| + } |
| + |
| // --- 2. platform-domain minting ------------------------------------------ |
| byHost := map[string]bool{} |
| // hasApex tracks the (ACCOUNT, app) pairs that already own a platform |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 370ecb56c..2afece8df 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "context" |
| "crypto/ecdsa" |
| + "crypto/ed25519" |
| "crypto/elliptic" |
| "crypto/rand" |
| "crypto/x509" |
| @@ -830,3 +831,169 @@ func TestReconcileIgnoresRetiredAppAccountManifests(t *testing.T) { |
| } |
| mustGet(t, reg, "kass.bab.oncarlos.test") |
| } |
| + |
| +// releaseKeys mints a root release key pair the way the deployment does, so |
| +// the purge pass has something real to verify a production flag against. |
| +func releaseKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| + t.Helper() |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seedB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return priv, pub |
| +} |
| + |
| +// appKeysLeft is what is still in the bucket under an app — the purge's only |
| +// observable effect. |
| +func appKeysLeft(t *testing.T, s release.Store, acct, app string) []string { |
| + t.Helper() |
| + keys, err := s.List(ctx, "apps/"+acct+"/"+app+"/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + slices.Sort(keys) |
| + return keys |
| +} |
| + |
| +// Past the cooloff, the pass DESTROYS: the app's objects and, last, its |
| +// marker. The neighbour app in the same account is untouched, and the pass |
| +// converges — a second run over an empty prefix changes nothing and does not |
| +// error. |
| +func TestReconcilePurgesAppsPastTheCooloff(t *testing.T) { |
| + _, pub := releaseKeys(t) |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json") |
| + putKey(t, s, "apps/"+sqid+"/derp/releases/1.0.0/manifest.json") |
| + putKey(t, s, "apps/"+sqid+"/keep/channels/stable.json") |
| + // Trashed 31 days before the config's clock. |
| + deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour) |
| + if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := cfg(t) |
| + c.ReleasePub = pub |
| + for pass := 1; pass <= 2; pass++ { |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 0 { |
| + t.Fatalf("pass %d: purge left objects behind: %v", pass, got) |
| + } |
| + if got := routesFor(t, reg, "derp"); len(got) != 0 { |
| + t.Errorf("pass %d: purged app still routed: %+v", pass, got) |
| + } |
| + if got := appKeysLeft(t, s, sqid, "keep"); len(got) != 1 { |
| + t.Fatalf("pass %d: the purge reached the neighbour app: %v", pass, got) |
| + } |
| + } |
| +} |
| + |
| +// Inside the cooloff the app is hidden but INTACT — that window is the whole |
| +// point of a trash, and restoring it must give the owner their bytes back. |
| +func TestReconcileDoesNotPurgeInsideTheCooloff(t *testing.T) { |
| + _, pub := releaseKeys(t) |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json") |
| + deleted := time.Unix(1754000000, 0).Add(-29 * 24 * time.Hour) |
| + if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := cfg(t) |
| + c.ReleasePub = pub |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Routes gone (the app is hidden), objects and marker still there. |
| + if got := routesFor(t, reg, "derp"); len(got) != 0 { |
| + t.Errorf("trashed app still routed: %+v", got) |
| + } |
| + want := []string{ |
| + "apps/" + sqid + "/derp/channels/stable.json", |
| + release.AppTombstoneKey(sqid, "derp"), |
| + } |
| + slices.Sort(want) |
| + if got := appKeysLeft(t, s, sqid, "derp"); !reflect.DeepEqual(got, want) { |
| + t.Fatalf("inside the cooloff the bucket = %v, want %v — nothing may be destroyed yet", got, want) |
| + } |
| + if tomb, err := release.ReadAppTombstone(ctx, s, sqid, "derp"); err != nil || tomb == nil { |
| + t.Fatalf("marker gone inside the cooloff: %v, %v", tomb, err) |
| + } |
| +} |
| + |
| +// A box with no release public key cannot verify a production flag, so it |
| +// cannot prove an app is safe to destroy: purging is OFF. Hiding still |
| +// happens — that is reversible, destruction is not. |
| +func TestReconcileWithoutAReleaseKeyHidesButNeverDestroys(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json") |
| + deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour) |
| + if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := cfg(t) // no ReleasePub |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := routesFor(t, reg, "derp"); len(got) != 0 { |
| + t.Errorf("trashed app still routed: %+v", got) |
| + } |
| + if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 2 { |
| + t.Fatalf("a keyless box destroyed objects: %v", got) |
| + } |
| +} |
| + |
| +// A trashed app that is ALSO flagged production is a contradiction the |
| +// console refuses to create — but if one ever exists in a bucket, the flag |
| +// wins and the bytes stay. Same for a flag that will not verify. |
| +func TestReconcileNeverPurgesAProductionFlaggedApp(t *testing.T) { |
| + priv, pub := releaseKeys(t) |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json") |
| + // The flag goes on FIRST: SetProduction refuses a trashed app. |
| + if err := release.SetProduction(ctx, s, priv, sqid, "derp", true, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour) |
| + if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := cfg(t) |
| + c.ReleasePub = pub |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 3 { |
| + t.Fatalf("a production-flagged app was purged: %v", got) |
| + } |
| + if tomb, err := release.ReadAppTombstone(ctx, s, sqid, "derp"); err != nil || tomb == nil { |
| + t.Fatalf("marker gone: %v, %v", tomb, err) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 49d4d016d..e9b50ca04 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -419,6 +419,14 @@ func domainsTickConfig(getenv func(string) string, publicIP string) domains.Conf |
| if len(ips) > 0 { |
| cfg.Resolve = resolveHost |
| } |
| + // The root release public key, same source and same parser as adoptLoop's. |
| + // Absence is TOLERATED and leaves ReleasePub nil, which switches the |
| + // auto-purge off: a box that cannot verify an app's production flag must |
| + // not destroy its objects. Everything else about the pass — including |
| + // hiding trashed apps, the reversible half — carries on regardless. |
| + if pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY"); err == nil { |
| + cfg.ReleasePub = pub |
| + } |
| return cfg |
| } |
| @@ -506,6 +514,9 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| return |
| } |
| log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v", cfg.AppsDomain, cfg.EdgeIPs) |
| + if cfg.ReleasePub == nil { |
| + log.Printf("agent: domains loop auto-purge disabled: CARLOS_RELEASE_PUBKEY is not set — trashed apps will be hidden but never destroyed") |
| + } |
| delay := time.Minute |
| for { |
| time.Sleep(delay) |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index a94cc4d65..2f806c5f7 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -354,6 +354,46 @@ func TestDomainsTickConfig(t *testing.T) { |
| } |
| } |
| +// TestDomainsTickConfigReleasePubKey pins the auto-purge's arming condition. |
| +// The reconcile pass destroys objects only when it can VERIFY an app's |
| +// production flag, so the key is what switches purging on — and a missing or |
| +// unparseable one leaves it off rather than failing the loop, because hiding |
| +// a trashed app (reversible) must keep working on a box that may not destroy |
| +// anything (irreversible). |
| +func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := domainsTickConfig(env(map[string]string{ |
| + "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| + "CARLOS_RELEASE_PUBKEY": pubB64, |
| + }), "9.9.9.9") |
| + want, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.ReleasePub.Equal(want) { |
| + t.Errorf("ReleasePub = %x, want %x", got.ReleasePub, want) |
| + } |
| + |
| + for _, key := range []string{"", "not-base64!!", base64.StdEncoding.EncodeToString([]byte("short"))} { |
| + got = domainsTickConfig(env(map[string]string{ |
| + "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| + "CARLOS_RELEASE_PUBKEY": key, |
| + }), "9.9.9.9") |
| + if got.ReleasePub != nil { |
| + t.Errorf("CARLOS_RELEASE_PUBKEY=%q armed purging with %x", key, got.ReleasePub) |
| + } |
| + if got.AppsDomain != "oncarlos.test" { |
| + t.Errorf("CARLOS_RELEASE_PUBKEY=%q disabled the whole pass", key) |
| + } |
| + } |
| +} |
| + |
| func TestBootstrapIdempotent(t *testing.T) { |
| root := t.TempDir() |
| if err := bootstrap(root); err != nil { |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index 8d158c9f9..f8a67b2ae 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -2,6 +2,7 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| "fmt" |
| @@ -86,3 +87,68 @@ func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombs |
| func RestoreApp(ctx context.Context, s Store, acct, app string) error { |
| return s.Delete(ctx, AppTombstoneKey(acct, app)) |
| } |
| + |
| +// ErrNotTrashed is PurgeApp's refusal to destroy an app that has no valid |
| +// trash marker: purge without an instruction is not a purge, it is a bug. |
| +// Distinguishable on purpose — a caller sweeping the trash reads it as |
| +// "already done", not as a failure worth alarming about. |
| +var ErrNotTrashed = errors.New("release: app is not in the trash") |
| + |
| +// PurgeApp irreversibly destroys a trashed app's bucket objects: every key |
| +// under apps/<acct>/<app>/, the trash marker LAST, so a purge that dies |
| +// half-way leaves a trashed app with fewer objects rather than a live app |
| +// with holes — the next pass finishes the job. |
| +// |
| +// This is the platform's first destructive automation surface, so every |
| +// guard fails toward keeping data: |
| +// - no valid marker → ErrNotTrashed (re-read HERE, immediately before |
| +// destruction — a restore that landed after the caller's listing wins); |
| +// - nil pub, production flag ON, or production flag UNREADABLE → refuse. |
| +// Flag ABSENT is fine: most apps never had one, and GetProduction says so |
| +// with (nil, nil). |
| +// |
| +// The AGE of the marker is deliberately the caller's decision, not this |
| +// function's: the auto-purge pass enforces TrashCooloff, and the console's |
| +// "Delete now" purges early behind its own ceremony. now is therefore taken |
| +// and not read — it is here so that every caller passes its OWN clock seam |
| +// and no future guard added inside this function has to reach for |
| +// time.Now() behind the tests' backs. |
| +func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error { |
| + tomb, err := ReadAppTombstone(ctx, s, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + if tomb == nil { |
| + return ErrNotTrashed |
| + } |
| + if len(pub) != ed25519.PublicKeySize { |
| + return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app) |
| + } |
| + // (nil, nil) here means the object is genuinely absent, which is the |
| + // default and not production. Every other outcome — unreadable, |
| + // unverifiable, misnamed — arrives as an error and refuses, because an |
| + // unreadable flag is exactly the state an attacker who can delete but not |
| + // sign can create. |
| + flag, err := GetProduction(ctx, s, pub, acct, app) |
| + if err != nil { |
| + return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err) |
| + } |
| + if flag != nil && flag.Production { |
| + return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app) |
| + } |
| + prefix := appPrefix(acct, app) |
| + marker := AppTombstoneKey(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return fmt.Errorf("release: list %s: %w", prefix, err) |
| + } |
| + for _, key := range keys { |
| + if key == marker { |
| + continue // last, below |
| + } |
| + if err := s.Delete(ctx, key); err != nil { |
| + return fmt.Errorf("release: purge %s: %w", key, err) |
| + } |
| + } |
| + return s.Delete(ctx, marker) |
| +} |
diff --git a/internal/release/trash_test.go b/internal/release/trash_test.go| index 104017c04..116b2793a 100644 |
| --- a/internal/release/trash_test.go |
| +++ b/internal/release/trash_test.go |
| @@ -2,10 +2,17 @@ package release |
| import ( |
| "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "slices" |
| "testing" |
| "time" |
| ) |
| +// ctx is the package's test context; the older tests in this file shadow it |
| +// with their own local one, harmlessly. |
| +var ctx = context.Background() |
| + |
| func TestTrashAppRoundTrip(t *testing.T) { |
| ctx := context.Background() |
| s := NewDirStore(t.TempDir()) |
| @@ -101,3 +108,199 @@ func TestReadAppTombstoneRefusesDisagreement(t *testing.T) { |
| t.Fatal("marker with zero deleted_at was not refused") |
| } |
| } |
| + |
| +// recordingStore is a Store that remembers the ORDER of Delete calls. The |
| +// purge's marker-last discipline is not observable from final state (in the |
| +// happy path everything is gone either way) — only from the sequence. |
| +type recordingStore struct { |
| + Store |
| + deletes *[]string |
| +} |
| + |
| +func (r recordingStore) Delete(ctx context.Context, key string) error { |
| + *r.deletes = append(*r.deletes, key) |
| + return r.Store.Delete(ctx, key) |
| +} |
| + |
| +// seedApp lays down a plausible app: two channel pointers, a manifest, an |
| +// artifact. The purge has to take all of them. |
| +func seedApp(t *testing.T, s Store, acct, app string) []string { |
| + t.Helper() |
| + keys := []string{ |
| + "apps/" + acct + "/" + app + "/channels/stable.json", |
| + "apps/" + acct + "/" + app + "/channels/canary/x.json", |
| + "apps/" + acct + "/" + app + "/releases/1.0.0/manifest.json", |
| + "apps/" + acct + "/" + app + "/releases/1.0.0/app.tar.gz", |
| + } |
| + for _, k := range keys { |
| + if _, err := s.Put(context.Background(), k, []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return keys |
| +} |
| + |
| +func listUnder(t *testing.T, s Store, prefix string) []string { |
| + t.Helper() |
| + keys, err := s.List(context.Background(), prefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + slices.Sort(keys) |
| + return keys |
| +} |
| + |
| +// Contract 1: purge without a marker is destruction without an instruction. |
| +func TestPurgeAppRefusesWhenNotTrashed(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + _, pub := testKeys(t) |
| + keys := seedApp(t, s, "bab", "derp") |
| + err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()) |
| + if !errors.Is(err, ErrNotTrashed) { |
| + t.Fatalf("PurgeApp on a live app = %v, want ErrNotTrashed", err) |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys) { |
| + t.Fatalf("a refused purge destroyed objects: %v", got) |
| + } |
| + // A marker that exists but cannot be believed is not an instruction |
| + // either: ReadAppTombstone's error propagates and nothing is destroyed. |
| + if _, err := s.Put(ctx, AppTombstoneKey("bab", "derp"), []byte("{"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatal("purge acted on an unparseable marker") |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys)+1 { |
| + t.Fatalf("a refused purge destroyed objects: %v", got) |
| + } |
| +} |
| + |
| +// Contract 2: no public key means the production flag cannot be verified, |
| +// which means the app cannot be PROVEN non-production. Refuse. |
| +func TestPurgeAppRefusesWithoutAPublicKey(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + keys := seedApp(t, s, "bab", "derp") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, pub := range []ed25519.PublicKey{nil, {}, make([]byte, 8)} { |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatalf("purge with a %d-byte key was allowed", len(pub)) |
| + } |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys)+1 { |
| + t.Fatalf("a refused purge destroyed objects: %v", got) |
| + } |
| +} |
| + |
| +// Contract 3: production ON refuses; an UNREADABLE flag refuses (unreadable |
| +// is not off); ABSENT is fine, because most apps never had a flag at all. |
| +func TestPurgeAppRefusesProductionAndUnreadableFlags(t *testing.T) { |
| + priv, pub := testKeys(t) |
| + |
| + // ON. |
| + s := NewDirStore(t.TempDir()) |
| + seedApp(t, s, "bab", "derp") |
| + if err := SetProduction(ctx, s, priv, "bab", "derp", true, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatal("purge destroyed a production-flagged app") |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) == 0 { |
| + t.Fatal("a refused purge destroyed objects") |
| + } |
| + |
| + // UNREADABLE: the object is there but does not verify. |
| + s = NewDirStore(t.TempDir()) |
| + seedApp(t, s, "bab", "derp") |
| + if _, err := s.Put(ctx, ProductionKey("bab", "derp"), []byte(`{"payload":"zz","sig":"zz"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatal("purge treated an unreadable production flag as off") |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) == 0 { |
| + t.Fatal("a refused purge destroyed objects") |
| + } |
| + |
| + // OFF, explicitly: purges. |
| + s = NewDirStore(t.TempDir()) |
| + seedApp(t, s, "bab", "derp") |
| + if err := SetProduction(ctx, s, priv, "bab", "derp", false, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("purge of an explicitly non-production app: %v", err) |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 { |
| + t.Fatalf("purge left objects behind: %v", got) |
| + } |
| +} |
| + |
| +// Contract 4: everything under the prefix goes, and the MARKER GOES LAST, so |
| +// a purge that dies half-way leaves a trashed app with fewer objects rather |
| +// than a live app with holes. |
| +func TestPurgeAppDestroysEverythingMarkerLast(t *testing.T) { |
| + base := NewDirStore(t.TempDir()) |
| + var deletes []string |
| + s := recordingStore{Store: base, deletes: &deletes} |
| + _, pub := testKeys(t) |
| + seedApp(t, s, "bab", "derp") |
| + // A neighbouring app in the same account must be untouched: the prefix |
| + // ends in "/" precisely so "derp" never reaches "derpy". |
| + seedApp(t, s, "bab", "derpy") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("PurgeApp: %v", err) |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 { |
| + t.Fatalf("purge left objects behind: %v", got) |
| + } |
| + if got := listUnder(t, s, "apps/bab/derpy/"); len(got) != 4 { |
| + t.Fatalf("purge reached the neighbouring app: %v", got) |
| + } |
| + marker := AppTombstoneKey("bab", "derp") |
| + if len(deletes) == 0 || deletes[len(deletes)-1] != marker { |
| + t.Fatalf("delete order %v — the marker must go LAST", deletes) |
| + } |
| + if slices.Index(deletes, marker) != len(deletes)-1 { |
| + t.Fatalf("marker deleted more than once / early: %v", deletes) |
| + } |
| +} |
| + |
| +// Contract 5: idempotent. A half-purged app converges on a second pass, and |
| +// an already-purged one reports ErrNotTrashed — which the caller reads as |
| +// "done", not as a failure. |
| +func TestPurgeAppIsIdempotent(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + _, pub := testKeys(t) |
| + seedApp(t, s, "bab", "derp") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Half-purged: some objects already gone, marker still standing. |
| + if err := s.Delete(ctx, "apps/bab/derp/channels/stable.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("purge of a half-purged app: %v", err) |
| + } |
| + if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 { |
| + t.Fatalf("purge left objects behind: %v", got) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); !errors.Is(err, ErrNotTrashed) { |
| + t.Fatalf("second purge = %v, want ErrNotTrashed", err) |
| + } |
| +} |