carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/appconfig"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// Environment stores (2026-09-19 spec): a store may belong to a named config
17// environment instead of to the app as a whole, and delivers into THAT
18// bundle. Everything here is about the boundary between the two — an
19// environment store and the app's own store share an app and nothing else.
20
21// declareIn is declare for an environment store: the member half of the
22// record, written at the environment's own key.
23func declareIn(t *testing.T, s release.Store, acct, app, env, prefix string) {
24 t.Helper()
25 rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}}
26 if _, err := PutIn(context.Background(), s, acct, app, env, rec, ""); err != nil {
27 t.Fatal(err)
28 }
29}
30
31func envVars(t *testing.T, s release.Store, acct, app, env string) map[string]appconfig.Var {
32 t.Helper()
33 m, err := appconfig.LoadEnvironment(context.Background(), s, acct, app, env)
34 if err != nil {
35 t.Fatal(err)
36 }
37 return m.Vars
38}
39
40// TestGrantInDeliversToTheEnvironmentBundle is the whole feature in one test:
41// the estate is named for the environment, and the credentials land where the
42// instances bound to that environment actually read — not in the app's
43// default bundle, which is what every instance NOT bound to it reads.
44func TestGrantInDeliversToTheEnvironmentBundle(t *testing.T) {
45 ctx := context.Background()
46 s := release.NewDirStore(t.TempDir())
47 priv, pub := testSealKeypair(t)
48 p := NewMemProvisioner("eu-west-1")
49 // Fix the secret the backend hands back, so the assertion below is about
50 // the value that actually reached the bundle rather than about two
51 // generated strings agreeing.
52 p.MintSecret = "beta-minted-secret"
53 declareIn(t, s, "bab", "titogo", "beta", "TITO_S3")
54
55 rec, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC())
56 if err != nil {
57 t.Fatal(err)
58 }
59 if got, want := rec.Provisioned.Bucket, "carlos-store-tito-bab-titogo-beta"; got != want {
60 t.Errorf("bucket = %q, want %q", got, want)
61 }
62 if got, want := rec.Provisioned.IAMUser, "carlos-store-bab-titogo-beta"; got != want {
63 t.Errorf("IAM user = %q, want %q", got, want)
64 }
65 if rec.Environment != "beta" {
66 t.Errorf("record environment = %q, want %q", rec.Environment, "beta")
67 }
68
69 // Delivered into the environment's bundle, sealed, and openable to the
70 // secret the backend actually minted.
71 vars := envVars(t, s, "bab", "titogo", "beta")
72 if got := vars["TITO_S3_BUCKET"].Value; got != "carlos-store-tito-bab-titogo-beta" {
73 t.Errorf("TITO_S3_BUCKET in the beta bundle = %q", got)
74 }
75 sealed := vars["TITO_S3_SECRET_KEY"]
76 if sealed.Kind != "secret" || sealed.Envelope == "" {
77 t.Fatalf("TITO_S3_SECRET_KEY = %+v, want a sealed secret", sealed)
78 }
79 plain, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, sealed.Envelope)
80 if err != nil {
81 t.Fatal(err)
82 }
83 if string(plain) != p.MintSecret {
84 t.Errorf("delivered secret = %q, want the minted %q", plain, p.MintSecret)
85 }
86
87 // And NOT into the default bundle. An instance with no environment
88 // binding must not be handed another environment's credentials.
89 for name := range mustVars(t, s, "bab", "titogo") {
90 if strings.HasPrefix(name, "TITO_S3_") {
91 t.Errorf("default bundle carries %s — an environment store delivered into the app's own bundle", name)
92 }
93 }
94
95 // A re-run is a no-op. This is the assertion that fails if the walk's
96 // idempotence short-circuit reads the DEFAULT bundle: every name would
97 // read as missing, and every walk would reap and mint a fresh key.
98 keyID := rec.Provisioned.KeyID
99 again, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC())
100 if err != nil {
101 t.Fatal(err)
102 }
103 if again.Provisioned.KeyID != keyID {
104 t.Errorf("re-run minted a fresh key (%s -> %s)", keyID, again.Provisioned.KeyID)
105 }
106}
107
108// TestEnvironmentStoreAndAppStoreAreSeparateEstates pins the isolation two
109// stores of one app must have: their own bucket, their own principal, their
110// own record, their own bundle.
111func TestEnvironmentStoreAndAppStoreAreSeparateEstates(t *testing.T) {
112 ctx := context.Background()
113 s := release.NewDirStore(t.TempDir())
114 _, pub := testSealKeypair(t)
115 p := NewMemProvisioner("eu-west-1")
116 d := testDeployment()
117
118 declare(t, s, "bab", "titogo", "TITO_S3")
119 appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC())
120 if err != nil {
121 t.Fatal(err)
122 }
123 declareIn(t, s, "bab", "titogo", "beta", "TITO_S3")
124 envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC())
125 if err != nil {
126 t.Fatal(err)
127 }
128
129 if appRec.Provisioned.Bucket == envRec.Provisioned.Bucket {
130 t.Errorf("both stores resolved to one bucket %q", appRec.Provisioned.Bucket)
131 }
132 if appRec.Provisioned.IAMUser == envRec.Provisioned.IAMUser {
133 t.Errorf("both stores resolved to one IAM user %q", appRec.Provisioned.IAMUser)
134 }
135 if appRec.Provisioned.KeyID == envRec.Provisioned.KeyID {
136 t.Error("both stores hold one access key")
137 }
138 if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket {
139 t.Errorf("default bundle bucket = %q, want the app store's %q", got, appRec.Provisioned.Bucket)
140 }
141 if got := envVars(t, s, "bab", "titogo", "beta")["TITO_S3_BUCKET"].Value; got != envRec.Provisioned.Bucket {
142 t.Errorf("beta bundle bucket = %q, want the environment store's %q", got, envRec.Provisioned.Bucket)
143 }
144}
145
146// TestGetInRefusesARecordThatDisagreesWithItsKey: Environment is stamped from
147// the key, so a record naming another environment is hand-edited bucket data.
148// Guessing which half is right would deliver one environment's credentials
149// under another's name.
150func TestGetInRefusesARecordThatDisagreesWithItsKey(t *testing.T) {
151 ctx := context.Background()
152 s := release.NewDirStore(t.TempDir())
153 // Write a beta-stamped record at the APP store's key, past PutIn's
154 // stamping, the way a hand edit would.
155 body := []byte(`{"schema":1,"app":"titogo","environment":"beta"}` + "\n")
156 if _, err := s.Put(ctx, Key("bab", "titogo"), body, ""); err != nil {
157 t.Fatal(err)
158 }
159 _, _, err := Get(ctx, s, "bab", "titogo")
160 if err == nil || !strings.Contains(err.Error(), "disagrees with its own key") {
161 t.Fatalf("Get over a mis-stamped record = %v, want a refusal", err)
162 }
163}
164
165// TestGrantInRefusesAnUnnameablePrincipal: the third name component can push
166// the IAM user name over AWS's ceiling. That must be refused in front of the
167// walk — a CreateUser failure would come after the grant block was written,
168// leaving a record naming an estate that was never created, which Teardown
169// then has to treat as live.
170//
171// The fixture needs a SHORT bucket prefix, and that is the whole shape of
172// this guard. Both names carry the same body: the bucket is
173// <prefix><body> against S3's 63, the principal is "carlos-store-"<body>
174// against IAM's 64. So the IAM ceiling is only reachable when the prefix is
175// shorter than the 13 characters of "carlos-store-" — under a longer one
176// (the flagship's "carlos-store-tito-") the bucket refusal always fires
177// first, and ValidUserName never speaks. Here: body 61, bucket exactly 63
178// and legal, principal 74 and refused.
179func TestGrantInRefusesAnUnnameablePrincipal(t *testing.T) {
180 ctx := context.Background()
181 s := release.NewDirStore(t.TempDir())
182 _, pub := testSealKeypair(t)
183 p := NewMemProvisioner("eu-west-1")
184 d := Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "s-"}
185 app := strings.Repeat("a", 24) // ValidApp caps at 32
186 env := strings.Repeat("e", 32) // ValidEnvironmentName caps at 32
187 declareIn(t, s, "bab", app, env, "TITO_S3")
188
189 if got := len(MintedBucketIn(d, "bab", app, env)); got != 63 {
190 t.Fatalf("fixture drift: bucket name is %d characters, want 63 (legal)", got)
191 }
192 _, err := GrantIn(ctx, s, p, d, pub, "bab", app, env, "op", GrantSourceOperator, "", time.Now().UTC())
193 if err == nil || !strings.Contains(err.Error(), "over AWS's 64") {
194 t.Fatalf("GrantIn with an unnameable principal = %v, want the length refusal", err)
195 }
196 if len(p.Buckets) != 0 || len(p.Users) != 0 {
197 t.Errorf("refused grant touched the backend: buckets %v users %v", p.Buckets, p.Users)
198 }
199 rec, _, err := GetIn(ctx, s, "bab", app, env)
200 if err != nil {
201 t.Fatal(err)
202 }
203 if rec.Grant != nil {
204 t.Error("refused grant wrote a grant block")
205 }
206}
207
208// TestTeardownInLeavesTheAppStoreStanding is the blast-radius test. Tearing
209// an environment store down must not touch the app's own estate, its bundle,
210// or — the one this spec had to guard explicitly — its scan sidecar, which is
211// the app-scoped sweep cursor an environment store does not have.
212func TestTeardownInLeavesTheAppStoreStanding(t *testing.T) {
213 ctx := context.Background()
214 s := release.NewDirStore(t.TempDir())
215 _, pub := testSealKeypair(t)
216 p := NewMemProvisioner("eu-west-1")
217 d := testDeployment()
218
219 declare(t, s, "bab", "titogo", "TITO_S3")
220 appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC())
221 if err != nil {
222 t.Fatal(err)
223 }
224 declareIn(t, s, "bab", "titogo", "beta", "TITO_S3")
225 envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC())
226 if err != nil {
227 t.Fatal(err)
228 }
229 // The app's sweep cursor, which only the app store owns.
230 if _, err := s.Put(ctx, ScanKey("bab", "titogo"), []byte(`{"schema":1}`+"\n"), ""); err != nil {
231 t.Fatal(err)
232 }
233
234 if err := TeardownIn(ctx, s, p, nil, "bab", "titogo", "beta"); err != nil {
235 t.Fatal(err)
236 }
237
238 // The environment's estate is gone.
239 if _, ok := p.Buckets[envRec.Provisioned.Bucket]; ok {
240 t.Error("environment bucket survived teardown")
241 }
242 if _, ok := p.Users[envRec.Provisioned.IAMUser]; ok {
243 t.Error("environment IAM user survived teardown")
244 }
245 if gone, _, err := GetIn(ctx, s, "bab", "titogo", "beta"); err != nil || gone != nil {
246 t.Errorf("environment record survived teardown: %+v, %v", gone, err)
247 }
248 for name := range envVars(t, s, "bab", "titogo", "beta") {
249 if strings.HasPrefix(name, "TITO_S3_") {
250 t.Errorf("beta bundle still carries %s", name)
251 }
252 }
253
254 // The app's own estate is untouched.
255 if _, ok := p.Buckets[appRec.Provisioned.Bucket]; !ok {
256 t.Error("app bucket was destroyed by an environment teardown")
257 }
258 if _, ok := p.Users[appRec.Provisioned.IAMUser]; !ok {
259 t.Error("app IAM user was destroyed by an environment teardown")
260 }
261 still, _, err := Get(ctx, s, "bab", "titogo")
262 if err != nil || still == nil || still.Provisioned == nil {
263 t.Fatalf("app store record = %+v, %v — want it standing", still, err)
264 }
265 if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket {
266 t.Errorf("app bundle lost its coordinates: TITO_S3_BUCKET = %q", got)
267 }
268 if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); err != nil {
269 t.Errorf("an environment teardown deleted the app's scan sidecar: %v", err)
270 }
271}
272
273// TestPurgeGuardSeesAnEnvironmentStore extends the parse agreement to the
274// newer shape. An app whose ONLY live estate is an environment store must
275// still refuse the box-side auto-purge: the guard reads store.json and every
276// stores/<env>.json, because a swept record is a paid bucket and a live IAM
277// user that nothing remembers.
278func TestPurgeGuardSeesAnEnvironmentStore(t *testing.T) {
279 ctx := context.Background()
280 s := release.NewDirStore(t.TempDir())
281 _, pub := testSealKeypair(t)
282 p := NewMemProvisioner("eu-west-1")
283 relPub, _, err := ed25519.GenerateKey(rand.Reader)
284 if err != nil {
285 t.Fatal(err)
286 }
287 // No app store at all — only an environment one.
288 declareIn(t, s, "bab", "titogo", "beta", "TITO_S3")
289 if _, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil {
290 t.Fatal(err)
291 }
292
293 backdated := time.Now().Add(-2 * release.TrashCooloff)
294 if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil {
295 t.Fatal(err)
296 }
297 err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now())
298 if !errors.Is(err, release.ErrStoreProvisioned) {
299 t.Fatalf("PurgeApp over a live environment store = %v, want release.ErrStoreProvisioned", err)
300 }
301 if _, _, err := s.Get(ctx, KeyIn("bab", "titogo", "beta")); err != nil {
302 t.Fatalf("the refused purge destroyed the environment store record: %v", err)
303 }
304}
305