package objstore import ( "context" "crypto/ed25519" "crypto/rand" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/release" ) // Environment stores (2026-09-19 spec): a store may belong to a named config // environment instead of to the app as a whole, and delivers into THAT // bundle. Everything here is about the boundary between the two — an // environment store and the app's own store share an app and nothing else. // declareIn is declare for an environment store: the member half of the // record, written at the environment's own key. func declareIn(t *testing.T, s release.Store, acct, app, env, prefix string) { t.Helper() rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}} if _, err := PutIn(context.Background(), s, acct, app, env, rec, ""); err != nil { t.Fatal(err) } } func envVars(t *testing.T, s release.Store, acct, app, env string) map[string]appconfig.Var { t.Helper() m, err := appconfig.LoadEnvironment(context.Background(), s, acct, app, env) if err != nil { t.Fatal(err) } return m.Vars } // TestGrantInDeliversToTheEnvironmentBundle is the whole feature in one test: // the estate is named for the environment, and the credentials land where the // instances bound to that environment actually read — not in the app's // default bundle, which is what every instance NOT bound to it reads. func TestGrantInDeliversToTheEnvironmentBundle(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) priv, pub := testSealKeypair(t) p := NewMemProvisioner("eu-west-1") // Fix the secret the backend hands back, so the assertion below is about // the value that actually reached the bundle rather than about two // generated strings agreeing. p.MintSecret = "beta-minted-secret" declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") rec, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } if got, want := rec.Provisioned.Bucket, "carlos-store-tito-bab-titogo-beta"; got != want { t.Errorf("bucket = %q, want %q", got, want) } if got, want := rec.Provisioned.IAMUser, "carlos-store-bab-titogo-beta"; got != want { t.Errorf("IAM user = %q, want %q", got, want) } if rec.Environment != "beta" { t.Errorf("record environment = %q, want %q", rec.Environment, "beta") } // Delivered into the environment's bundle, sealed, and openable to the // secret the backend actually minted. vars := envVars(t, s, "bab", "titogo", "beta") if got := vars["TITO_S3_BUCKET"].Value; got != "carlos-store-tito-bab-titogo-beta" { t.Errorf("TITO_S3_BUCKET in the beta bundle = %q", got) } sealed := vars["TITO_S3_SECRET_KEY"] if sealed.Kind != "secret" || sealed.Envelope == "" { t.Fatalf("TITO_S3_SECRET_KEY = %+v, want a sealed secret", sealed) } plain, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, sealed.Envelope) if err != nil { t.Fatal(err) } if string(plain) != p.MintSecret { t.Errorf("delivered secret = %q, want the minted %q", plain, p.MintSecret) } // And NOT into the default bundle. An instance with no environment // binding must not be handed another environment's credentials. for name := range mustVars(t, s, "bab", "titogo") { if strings.HasPrefix(name, "TITO_S3_") { t.Errorf("default bundle carries %s — an environment store delivered into the app's own bundle", name) } } // A re-run is a no-op. This is the assertion that fails if the walk's // idempotence short-circuit reads the DEFAULT bundle: every name would // read as missing, and every walk would reap and mint a fresh key. keyID := rec.Provisioned.KeyID again, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } if again.Provisioned.KeyID != keyID { t.Errorf("re-run minted a fresh key (%s -> %s)", keyID, again.Provisioned.KeyID) } } // TestEnvironmentStoreAndAppStoreAreSeparateEstates pins the isolation two // stores of one app must have: their own bucket, their own principal, their // own record, their own bundle. func TestEnvironmentStoreAndAppStoreAreSeparateEstates(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) _, pub := testSealKeypair(t) p := NewMemProvisioner("eu-west-1") d := testDeployment() declare(t, s, "bab", "titogo", "TITO_S3") appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } if appRec.Provisioned.Bucket == envRec.Provisioned.Bucket { t.Errorf("both stores resolved to one bucket %q", appRec.Provisioned.Bucket) } if appRec.Provisioned.IAMUser == envRec.Provisioned.IAMUser { t.Errorf("both stores resolved to one IAM user %q", appRec.Provisioned.IAMUser) } if appRec.Provisioned.KeyID == envRec.Provisioned.KeyID { t.Error("both stores hold one access key") } if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { t.Errorf("default bundle bucket = %q, want the app store's %q", got, appRec.Provisioned.Bucket) } if got := envVars(t, s, "bab", "titogo", "beta")["TITO_S3_BUCKET"].Value; got != envRec.Provisioned.Bucket { t.Errorf("beta bundle bucket = %q, want the environment store's %q", got, envRec.Provisioned.Bucket) } } // TestGetInRefusesARecordThatDisagreesWithItsKey: Environment is stamped from // the key, so a record naming another environment is hand-edited bucket data. // Guessing which half is right would deliver one environment's credentials // under another's name. func TestGetInRefusesARecordThatDisagreesWithItsKey(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) // Write a beta-stamped record at the APP store's key, past PutIn's // stamping, the way a hand edit would. body := []byte(`{"schema":1,"app":"titogo","environment":"beta"}` + "\n") if _, err := s.Put(ctx, Key("bab", "titogo"), body, ""); err != nil { t.Fatal(err) } _, _, err := Get(ctx, s, "bab", "titogo") if err == nil || !strings.Contains(err.Error(), "disagrees with its own key") { t.Fatalf("Get over a mis-stamped record = %v, want a refusal", err) } } // TestGrantInRefusesAnUnnameablePrincipal: the third name component can push // the IAM user name over AWS's ceiling. That must be refused in front of the // walk — a CreateUser failure would come after the grant block was written, // leaving a record naming an estate that was never created, which Teardown // then has to treat as live. // // The fixture needs a SHORT bucket prefix, and that is the whole shape of // this guard. Both names carry the same body: the bucket is // against S3's 63, the principal is "carlos-store-" // against IAM's 64. So the IAM ceiling is only reachable when the prefix is // shorter than the 13 characters of "carlos-store-" — under a longer one // (the flagship's "carlos-store-tito-") the bucket refusal always fires // first, and ValidUserName never speaks. Here: body 61, bucket exactly 63 // and legal, principal 74 and refused. func TestGrantInRefusesAnUnnameablePrincipal(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) _, pub := testSealKeypair(t) p := NewMemProvisioner("eu-west-1") d := Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "s-"} app := strings.Repeat("a", 24) // ValidApp caps at 32 env := strings.Repeat("e", 32) // ValidEnvironmentName caps at 32 declareIn(t, s, "bab", app, env, "TITO_S3") if got := len(MintedBucketIn(d, "bab", app, env)); got != 63 { t.Fatalf("fixture drift: bucket name is %d characters, want 63 (legal)", got) } _, err := GrantIn(ctx, s, p, d, pub, "bab", app, env, "op", GrantSourceOperator, "", time.Now().UTC()) if err == nil || !strings.Contains(err.Error(), "over AWS's 64") { t.Fatalf("GrantIn with an unnameable principal = %v, want the length refusal", err) } if len(p.Buckets) != 0 || len(p.Users) != 0 { t.Errorf("refused grant touched the backend: buckets %v users %v", p.Buckets, p.Users) } rec, _, err := GetIn(ctx, s, "bab", app, env) if err != nil { t.Fatal(err) } if rec.Grant != nil { t.Error("refused grant wrote a grant block") } } // TestTeardownInLeavesTheAppStoreStanding is the blast-radius test. Tearing // an environment store down must not touch the app's own estate, its bundle, // or — the one this spec had to guard explicitly — its scan sidecar, which is // the app-scoped sweep cursor an environment store does not have. func TestTeardownInLeavesTheAppStoreStanding(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) _, pub := testSealKeypair(t) p := NewMemProvisioner("eu-west-1") d := testDeployment() declare(t, s, "bab", "titogo", "TITO_S3") appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) if err != nil { t.Fatal(err) } // The app's sweep cursor, which only the app store owns. if _, err := s.Put(ctx, ScanKey("bab", "titogo"), []byte(`{"schema":1}`+"\n"), ""); err != nil { t.Fatal(err) } if err := TeardownIn(ctx, s, p, nil, "bab", "titogo", "beta"); err != nil { t.Fatal(err) } // The environment's estate is gone. if _, ok := p.Buckets[envRec.Provisioned.Bucket]; ok { t.Error("environment bucket survived teardown") } if _, ok := p.Users[envRec.Provisioned.IAMUser]; ok { t.Error("environment IAM user survived teardown") } if gone, _, err := GetIn(ctx, s, "bab", "titogo", "beta"); err != nil || gone != nil { t.Errorf("environment record survived teardown: %+v, %v", gone, err) } for name := range envVars(t, s, "bab", "titogo", "beta") { if strings.HasPrefix(name, "TITO_S3_") { t.Errorf("beta bundle still carries %s", name) } } // The app's own estate is untouched. if _, ok := p.Buckets[appRec.Provisioned.Bucket]; !ok { t.Error("app bucket was destroyed by an environment teardown") } if _, ok := p.Users[appRec.Provisioned.IAMUser]; !ok { t.Error("app IAM user was destroyed by an environment teardown") } still, _, err := Get(ctx, s, "bab", "titogo") if err != nil || still == nil || still.Provisioned == nil { t.Fatalf("app store record = %+v, %v — want it standing", still, err) } if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { t.Errorf("app bundle lost its coordinates: TITO_S3_BUCKET = %q", got) } if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); err != nil { t.Errorf("an environment teardown deleted the app's scan sidecar: %v", err) } } // TestPurgeGuardSeesAnEnvironmentStore extends the parse agreement to the // newer shape. An app whose ONLY live estate is an environment store must // still refuse the box-side auto-purge: the guard reads store.json and every // stores/.json, because a swept record is a paid bucket and a live IAM // user that nothing remembers. func TestPurgeGuardSeesAnEnvironmentStore(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) _, pub := testSealKeypair(t) p := NewMemProvisioner("eu-west-1") relPub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } // No app store at all — only an environment one. declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") if _, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { t.Fatal(err) } backdated := time.Now().Add(-2 * release.TrashCooloff) if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil { t.Fatal(err) } err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()) if !errors.Is(err, release.ErrStoreProvisioned) { t.Fatalf("PurgeApp over a live environment store = %v, want release.ErrStoreProvisioned", err) } if _, _, err := s.Get(ctx, KeyIn("bab", "titogo", "beta")); err != nil { t.Fatalf("the refused purge destroyed the environment store record: %v", err) } }