package console import ( "context" "encoding/json" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/usage" ) // An app may have several stores since 2026-09-19 — its own, and one per // config environment. Two places have to count them ALL: the metering pass, // which bills for them, and the purge path, which is the only door with the // authority to tear them down. func provisionedRecord(bucket string) *objstore.Record { now := time.Now().UTC() return &objstore.Record{ Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: now, By: "member@example.com"}, Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, Source: objstore.GrantSourceOperator, At: now, By: "op"}, Provisioned: &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: "u-" + bucket, KeyID: "K", At: now}, } } func readStorageDay(t *testing.T, fx *consoleFixture, date string) usage.StorageDay { t.Helper() raw, _, err := fx.rel.Get(context.Background(), usage.StorageDayKey(date)) if err != nil { t.Fatal(err) } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } return day } // The bill is the sum over every store of the app. Sizes differ per bucket so // this fails if either store is dropped, rather than passing on a coincidence. func TestStoragePassSumsEveryStoreOfAnApp(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil { t.Fatal(err) } if _, err := objstore.PutIn(ctx, fx.rel, fixtureAcct, fixtureApp, "beta", provisionedRecord("beta-bucket"), ""); err != nil { t.Fatal(err) } fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) { switch p.Bucket { case "app-bucket": return 1000, nil case "beta-bucket": return 2000, nil } t.Errorf("sized an unexpected bucket %q", p.Bucket) return 0, nil } if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil { t.Fatal(err) } app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp] if app.StoreBytes != 3000 { t.Errorf("StoreBytes = %d, want 3000 (1000 app + 2000 beta)", app.StoreBytes) } if !app.StoreProvisioned { t.Error("StoreProvisioned = false, want true") } if app.StoreUnswept { t.Error("StoreUnswept = true, but every store was sized") } } // "Could not ask" is not "no bytes". The day object is create-only, so a // zero written here can never be corrected — an unreadable environment store // has to mark the day unswept, exactly as an unreadable app store does. func TestStoragePassMarksUnsweptOnAnUnreadableEnvironmentStore(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil { t.Fatal(err) } if _, err := fx.rel.Put(ctx, objstore.KeyIn(fixtureAcct, fixtureApp, "beta"), []byte("{not valid json"), ""); err != nil { t.Fatal(err) } fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) { return 1000, nil } if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil { t.Fatal(err) } app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp] if !app.StoreUnswept { t.Error("StoreUnswept = false, but one of this app's stores could not be read") } // The store it COULD read is still counted: a partial figure marked // unswept is honest, a dropped one is not. if app.StoreBytes != 1000 { t.Errorf("StoreBytes = %d, want the app store's 1000", app.StoreBytes) } } // grantStoreIn drives the real doors, so the provisioner genuinely holds a // bucket, an IAM user and a key for each store — the estate a purge has to // find. grantStore (apiscan_test.go) is this with no environment. func grantStoreIn(t *testing.T, fx *consoleFixture, token, env string) { t.Helper() declareURL, grantURL := storeURL(fx, "titogo"), storeURL(fx, "titogo")+"/grant" if env != "" { declareURL = storeEnvURL(fx, "titogo", env) grantURL += "?environment=" + env } if status, out := postJSON(t, declareURL, map[string]any{}, token); status != http.StatusOK { t.Fatalf("declare %q = %d %v", env, status, out) } if status, out := postJSON(t, grantURL, map[string]any{}, token); status != http.StatusOK { t.Fatalf("grant %q = %d %v", env, status, out) } } // Purging an app tears down EVERY store it has. release.PurgeApp refuses the // box-side auto-purge while any of them holds a grant or provisioned block, // so a purge that missed one would not leave a partial cleanup — it would // leave an app that can never be purged at all. func TestPurgeAppStoresTearsDownEveryStore(t *testing.T) { ctx := context.Background() fx, fake, token, _ := storeArmed(t, "code-purge-every-store") grantStoreIn(t, fx, token, "") grantStoreIn(t, fx, token, "beta") if len(fake.Buckets) != 2 || len(fake.Users) != 2 { t.Fatalf("fixture drift: buckets %v users %v, want one of each per store", fake.Buckets, fake.Users) } acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} if msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev"); msg != "" { t.Fatalf("purgeAppStores refused: %s", msg) } if len(fake.Buckets) != 0 { t.Errorf("buckets survived the purge: %v", fake.Buckets) } if len(fake.Users) != 0 { t.Errorf("IAM users survived the purge: %v", fake.Users) } for _, env := range []string{"", "beta"} { rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", env) if err != nil { t.Fatal(err) } if rec != nil { t.Errorf("store record for %q survived the purge: %+v", env, rec) } } } // A console with no storage backend cannot tear an estate down, so it must // refuse rather than let the prefix sweep delete the only records naming a // live bucket and a live credential. func TestPurgeAppStoresRefusesWithNoProvisioner(t *testing.T) { ctx := context.Background() fx, fake, token, _ := storeArmed(t, "code-purge-no-backend") grantStoreIn(t, fx, token, "beta") fx.srv.cfg.StoreProvisioner = nil acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev") if msg == "" { t.Fatal("purgeAppStores allowed a purge it could not carry out") } if len(fake.Buckets) != 1 { t.Errorf("the refused purge touched the backend: %v", fake.Buckets) } rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", "beta") if err != nil { t.Fatal(err) } if rec == nil { t.Error("the refused purge deleted the record naming a live estate") } }