carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The member half of the object-storage door (issue #55): declare a store,
4// then ask what became of it. What is pinned here, in the order it would hurt
5// if lost: a stranger probing either door learns nothing (the byte-identical
6// 404 the whole /api/cli surface keeps); the env prefix a provisioned store
7// delivers under cannot be changed out from under the app; and `status` tells
8// the truth about delivery — which variables have NOT arrived, and whether a
9// rotation is still half-done — because that is the only sight a member with
10// no box access has of either.
11
12import (
13 "context"
14 "encoding/json"
15 "errors"
16 "net/http"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/carlosframework/platform/internal/appconfig"
22 "github.com/carlosframework/platform/internal/objstore"
23 "github.com/carlosframework/platform/internal/release"
24 "github.com/carlosframework/platform/internal/usage"
25)
26
27func storeFixture(t *testing.T, code string) (*consoleFixture, string) {
28 t.Helper()
29 fx := newConsoleFixture(t)
30 now := time.Now()
31 if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
32 t.Fatal(err)
33 }
34 // A MEMBER, not an owner: declaring your own app's store is a member
35 // action, like promote below stable — the operator half (grant, rotate,
36 // destroy) is a different door with a different authority.
37 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
38 t.Fatal(err)
39 }
40 return fx, cliToken(t, fx, "alice@keymail.dev", code)
41}
42
43func storeURL(fx *consoleFixture, app string) string {
44 return fx.ts.URL + "/api/cli/apps/bab/" + app + "/store"
45}
46
47// storedRecord reads the record straight out of the bucket — the check that a
48// 200 actually landed, and that a refusal left nothing behind. nil means no
49// object at all.
50func storedRecord(t *testing.T, fx *consoleFixture, app string) *objstore.Record {
51 t.Helper()
52 rec, _, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, app)
53 if err != nil {
54 t.Fatalf("read store record: %v", err)
55 }
56 return rec
57}
58
59// putRecord hand-writes a record the console itself would only reach through
60// the operator doors (Task 9's grant/rotate) — the state this door has to
61// REPORT on, and refuse to contradict, long before that door exists.
62func putRecord(t *testing.T, fx *consoleFixture, app string, rec *objstore.Record) {
63 t.Helper()
64 if _, err := objstore.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, ""); err != nil {
65 t.Fatalf("seed store record: %v", err)
66 }
67}
68
69func strList(t *testing.T, out map[string]any, key string) []string {
70 t.Helper()
71 raw, ok := out[key].([]any)
72 if !ok {
73 t.Fatalf("%s = %#v, want a list", key, out[key])
74 }
75 got := make([]string, len(raw))
76 for i, v := range raw {
77 s, ok := v.(string)
78 if !ok {
79 t.Fatalf("%s[%d] = %#v, want a string", key, i, v)
80 }
81 got[i] = s
82 }
83 return got
84}
85
86func block(t *testing.T, out map[string]any, key string) map[string]any {
87 t.Helper()
88 m, ok := out[key].(map[string]any)
89 if !ok {
90 t.Fatalf("%s = %#v, want an object", key, out[key])
91 }
92 return m
93}
94
95// ---- declare ----------------------------------------------------------------
96
97// TestAPIStoreDeclareWritesTheRecord: the whole happy path. An empty body
98// means the default prefix, the record lands in the bucket with the caller as
99// its author, the answer names the variables the app will eventually be given,
100// and NO serial is bumped — nothing a box reads has changed, so waking the
101// fleet would be noise.
102func TestAPIStoreDeclareWritesTheRecord(t *testing.T) {
103 fx, token := storeFixture(t, "code-store-declare")
104 if rec := storedRecord(t, fx, "titogo"); rec != nil {
105 t.Fatalf("a store record exists before any declaration: %+v", rec)
106 }
107
108 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
109 if status != http.StatusOK {
110 t.Fatalf("declare = %d %v, want 200", status, out)
111 }
112 if str(t, out, "app") != "titogo" {
113 t.Fatalf("app = %v, want titogo", out["app"])
114 }
115 declared := block(t, out, "declared")
116 if declared["env_prefix"] != objstore.DefaultEnvPrefix {
117 t.Fatalf("env_prefix = %v, want the default %q", declared["env_prefix"], objstore.DefaultEnvPrefix)
118 }
119 if declared["by"] != "alice@keymail.dev" {
120 t.Fatalf("declared.by = %v, want the caller", declared["by"])
121 }
122 if out["grant"] != nil || out["provisioned"] != nil {
123 t.Fatalf("grant/provisioned = %v/%v, want null on a fresh declaration", out["grant"], out["provisioned"])
124 }
125 // This fixture configures no backend, and the answer says so — the field
126 // is what separates "queued behind an operator" from "this deployment
127 // will never provision one".
128 if out["backend"] != "" {
129 t.Fatalf("backend = %v, want \"\" on a deployment with no object storage", out["backend"])
130 }
131 // No endpoint name: this deployment provisions none, and a name the app
132 // will never be given would send someone hunting for a missing variable.
133 wantNames := []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"}
134 if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
135 t.Fatalf("env_names = %v, want %v", got, wantNames)
136 }
137 if out["pending_finish"] != false {
138 t.Fatalf("pending_finish = %v, want false", out["pending_finish"])
139 }
140 if got := strList(t, out, "missing_vars"); len(got) != 0 {
141 t.Fatalf("missing_vars = %v, want empty until the store is provisioned", got)
142 }
143
144 rec := storedRecord(t, fx, "titogo")
145 if rec == nil || rec.Declared == nil {
146 t.Fatalf("record after a 200 = %+v, want a declaration in the bucket", rec)
147 }
148 if rec.App != "titogo" || rec.Schema != objstore.Schema {
149 t.Fatalf("record = %+v, want app titogo at schema %d", rec, objstore.Schema)
150 }
151 if rec.Declared.EnvPrefix != objstore.DefaultEnvPrefix || rec.Declared.By != "alice@keymail.dev" {
152 t.Fatalf("record declaration = %+v", rec.Declared)
153 }
154 // Nothing box-visible changed: no version moved, no config was written.
155 if got := serial(t, fx); got != 0 {
156 t.Fatalf("serial after a declaration = %d, want 0 — declaring delivers nothing to a box", got)
157 }
158 auditedBy(t, fx, "titogo", "store-create", "alice@keymail.dev", objstore.DefaultEnvPrefix)
159}
160
161// TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent: a chosen prefix renames
162// every delivered variable, and re-declaring the same prefix preserves the
163// original declaration. A second member can safely run create again.
164func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) {
165 fx, token := storeFixture(t, "code-store-prefix")
166
167 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token)
168 if status != http.StatusOK {
169 t.Fatalf("declare = %d %v", status, out)
170 }
171 wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
172 if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
173 t.Fatalf("env_names = %v, want %v", got, wantNames)
174 }
175
176 if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil {
177 t.Fatal(err)
178 }
179 bob := cliToken(t, fx, "bob@keymail.dev", "code-store-prefix-bob")
180 status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, bob)
181 if status != http.StatusOK {
182 t.Fatalf("same-prefix re-declare = %d %v, want 200", status, out)
183 }
184 rec := storedRecord(t, fx, "titogo")
185 if rec == nil || rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" {
186 t.Fatalf("record after re-declare = %+v", rec)
187 }
188 // A retry preserves the original author and does not race a grant write.
189 if rec.Declared.By != "alice@keymail.dev" {
190 t.Fatalf("declared.by = %q, want the original caller", rec.Declared.By)
191 }
192}
193
194// TestAPIStoreDeclareRefusesAnInvalidPrefix: the refusal names the charset,
195// because a caller who typed a lowercase prefix has to learn the rule from the
196// answer — and nothing is written, so a corrected retry starts from nothing.
197func TestAPIStoreDeclareRefusesAnInvalidPrefix(t *testing.T) {
198 fx, token := storeFixture(t, "code-store-badprefix")
199
200 for _, bad := range []string{"blobs", "9BLOBS", "BLOBS-X", strings.Repeat("A", 33)} {
201 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": bad}, token)
202 if status != http.StatusBadRequest {
203 t.Fatalf("declare %q = %d %v, want 400", bad, status, out)
204 }
205 if !strings.Contains(str(t, out, "error"), "^[A-Z][A-Z0-9_]{0,31}$") {
206 t.Fatalf("declare %q error = %q, want the charset named", bad, out["error"])
207 }
208 if rec := storedRecord(t, fx, "titogo"); rec != nil {
209 t.Fatalf("a refused declaration wrote a record: %+v", rec)
210 }
211 }
212}
213
214// TestAPIStoreNamesTheDeploymentBackend: a member with no infra access has no
215// other way to tell a declaration waiting on an operator from one waiting on a
216// deployment that has no object storage at all — and both doors have to agree
217// about it, since `carlos store create` is where most people will look first.
218func TestAPIStoreNamesTheDeploymentBackend(t *testing.T) {
219 fx, token := storeFixture(t, "code-store-backend")
220 fx.srv.cfg.StoreDeployment = objstore.Deployment{
221 Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-",
222 }
223
224 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
225 if status != http.StatusOK {
226 t.Fatalf("declare = %d %v", status, out)
227 }
228 if out["backend"] != "aws" {
229 t.Fatalf("declare backend = %v, want aws", out["backend"])
230 }
231 status, out = getJSON(t, storeURL(fx, "titogo"), token)
232 if status != http.StatusOK {
233 t.Fatalf("status = %d %v", status, out)
234 }
235 if out["backend"] != "aws" {
236 t.Fatalf("status backend = %v, want aws", out["backend"])
237 }
238}
239
240// TestAPIStorePrefixIsFixedOnceGranted: the mid-grant window. The grant block
241// is written FIRST and the variables are delivered several steps before
242// `provisioned` lands, so a guard keyed on `provisioned` alone would accept a
243// rename in exactly the window where vars already exist under the old prefix —
244// orphaning them against a key the next pass reaps.
245func TestAPIStorePrefixIsFixedOnceGranted(t *testing.T) {
246 fx, token := storeFixture(t, "code-store-grant-locked")
247 now := time.Now()
248 putRecord(t, fx, "titogo", &objstore.Record{
249 Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"},
250 Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"},
251 })
252
253 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token)
254 if status != http.StatusConflict {
255 t.Fatalf("re-declare mid-grant = %d %v, want 409", status, out)
256 }
257 if msg := str(t, out, "error"); !strings.Contains(msg, "destroy and re-create") {
258 t.Fatalf("error = %q, want the way out named", msg)
259 }
260 // It must not claim a bucket exists — nothing has been provisioned yet.
261 if msg := str(t, out, "error"); strings.Contains(msg, "already provisioned") {
262 t.Fatalf("error = %q, but this store is granted, not provisioned", msg)
263 }
264 if rec := storedRecord(t, fx, "titogo"); rec.Declared.EnvPrefix != "BLOBS" {
265 t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix)
266 }
267 // The same prefix remains a no-op: a re-run of `carlos store create` must
268 // not turn into an error the moment an operator picks the request up.
269 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token); status != http.StatusOK {
270 t.Fatalf("same-prefix re-declare mid-grant = %d %v, want 200", status, out)
271 }
272}
273
274// TestAPIStorePrefixIsFixedOnceProvisioned: the delivered variable names are
275// what a RUNNING app reads. Renaming them under a provisioned store would
276// leave the app looking for variables nothing will ever write again, so the
277// door refuses and says how to actually change it. Same prefix still passes:
278// a re-run of `carlos store create` must not become an error the day the
279// store gets provisioned.
280func TestAPIStorePrefixIsFixedOnceProvisioned(t *testing.T) {
281 fx, token := storeFixture(t, "code-store-locked")
282 now := time.Now()
283 putRecord(t, fx, "titogo", &objstore.Record{
284 Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"},
285 Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"},
286 Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-titogo", Region: "eu-west-1", IAMUser: "carlos-store-bab-titogo", KeyID: "AKIAEXAMPLE", At: now},
287 })
288
289 status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token)
290 if status != http.StatusConflict {
291 t.Fatalf("re-declare with a new prefix = %d %v, want 409", status, out)
292 }
293 if msg := str(t, out, "error"); !strings.Contains(msg, "destroy and re-create") {
294 t.Fatalf("error = %q, want the way out named", msg)
295 }
296 rec := storedRecord(t, fx, "titogo")
297 if rec.Declared.EnvPrefix != "BLOBS" {
298 t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix)
299 }
300
301 status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token)
302 if status != http.StatusOK {
303 t.Fatalf("same-prefix re-declare on a provisioned store = %d %v, want 200", status, out)
304 }
305 // The provisioning survives the no-op — the reply echoes the whole
306 // record, not just the half this door writes.
307 if got := block(t, out, "provisioned")["bucket"]; got != "carlos-bab-titogo" {
308 t.Fatalf("provisioned.bucket = %v after a re-declare", got)
309 }
310 if rec := storedRecord(t, fx, "titogo"); rec.Provisioned == nil || rec.Provisioned.KeyID != "AKIAEXAMPLE" {
311 t.Fatalf("re-declare clobbered the provisioning: %+v", rec)
312 }
313}
314
315// ---- status -----------------------------------------------------------------
316
317// TestAPIStoreStatusIs404BeforeAnyDeclaration: an app with no store answers
318// 404 — but a SPEAKING one. This 404 fires only after the member gate has
319// passed, so the caller is entitled to the sentence; the gate's own refusals
320// (below) stay byte-identical with the stdlib's.
321func TestAPIStoreStatusIs404BeforeAnyDeclaration(t *testing.T) {
322 fx, token := storeFixture(t, "code-store-absent")
323
324 status, out := getJSON(t, storeURL(fx, "titogo"), token)
325 if status != http.StatusNotFound {
326 t.Fatalf("status with no store = %d %v, want 404", status, out)
327 }
328 if msg := str(t, out, "error"); !strings.Contains(msg, "no store is declared") {
329 t.Fatalf("status error = %q, want it to say no store is declared", msg)
330 }
331}
332
333// TestAPIStoreStatusReportsDeliveryAndPendingFinish: the two facts a member
334// with no box access cannot see any other way — which delivered variables have
335// NOT arrived in the app's config, and whether a rotation is still holding the
336// old key alive. missing_vars is computed against the app's real manifest, so
337// a half-delivered store reads as half-delivered rather than "provisioned".
338func TestAPIStoreStatusReportsDeliveryAndPendingFinish(t *testing.T) {
339 fx, token := storeFixture(t, "code-store-status")
340 now := time.Now()
341 putRecord(t, fx, "titogo", &objstore.Record{
342 Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"},
343 Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"},
344 Provisioned: &objstore.Provisioned{
345 Bucket: "carlos-bab-titogo", Region: "eu-west-1", Endpoint: "https://s3.example.com",
346 IAMUser: "carlos-store-bab-titogo", UserARN: "arn:aws:iam::1:user/carlos-store-bab-titogo",
347 KeyID: "AKIANEW", PrevKeyID: "AKIAOLD", At: now,
348 },
349 })
350 // Only the bucket coordinate ever landed in the app's config.
351 if _, err := appconfig.Apply(context.Background(), fx.rel, release.OpsAccount, "titogo",
352 map[string]appconfig.Var{"BLOBS_BUCKET": {Kind: "env", Value: "carlos-bab-titogo"}}, nil); err != nil {
353 t.Fatal(err)
354 }
355
356 status, out := getJSON(t, storeURL(fx, "titogo"), token)
357 if status != http.StatusOK {
358 t.Fatalf("status = %d %v", status, out)
359 }
360 prov := block(t, out, "provisioned")
361 if prov["key_id"] != "AKIANEW" || prov["prev_key_id"] != "AKIAOLD" {
362 t.Fatalf("provisioned = %v, want both key ids echoed (they are not secrets)", prov)
363 }
364 if out["pending_finish"] != true {
365 t.Fatalf("pending_finish = %v, want true while a previous key is still live", out["pending_finish"])
366 }
367 // The endpoint name appears only because this store HAS an endpoint.
368 wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
369 if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
370 t.Fatalf("env_names = %v, want %v", got, wantNames)
371 }
372 wantMissing := []string{"BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
373 if got := strList(t, out, "missing_vars"); strings.Join(got, ",") != strings.Join(wantMissing, ",") {
374 t.Fatalf("missing_vars = %v, want %v", got, wantMissing)
375 }
376}
377
378// TestAPIStoreStatusOfADeclaredButUnprovisionedStore: no bucket exists yet, so
379// there is nothing to be missing — an app that has merely ASKED for a store
380// must not read as four undelivered variables, which is what a caller would
381// chase down.
382func TestAPIStoreStatusOfADeclaredButUnprovisionedStore(t *testing.T) {
383 fx, token := storeFixture(t, "code-store-declared-only")
384 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
385 t.Fatalf("declare = %d %v", status, out)
386 }
387
388 status, out := getJSON(t, storeURL(fx, "titogo"), token)
389 if status != http.StatusOK {
390 t.Fatalf("status = %d %v", status, out)
391 }
392 if got := strList(t, out, "missing_vars"); len(got) != 0 {
393 t.Fatalf("missing_vars = %v, want empty for a store nobody has provisioned", got)
394 }
395 if out["pending_finish"] != false {
396 t.Fatalf("pending_finish = %v, want false", out["pending_finish"])
397 }
398}
399
400// ---- the gate ---------------------------------------------------------------
401
402// TestAPIStoreGateIsByteIdentical: on BOTH doors, a token that belongs to no
403// account, an account segment that doesn't exist and an app the account never
404// claimed are one and the same 404 — byte-identical to http.NotFound's own
405// output, so probing this API teaches a token holder nothing about which
406// accounts or apps exist, and never that one has a store.
407func TestAPIStoreGateIsByteIdentical(t *testing.T) {
408 fx, token := storeFixture(t, "code-store-gate")
409 eve := cliToken(t, fx, "eve@keymail.dev", "code-store-gate-eve")
410
411 doors := []struct {
412 name string
413 call func(u, bearer string) (int, string)
414 }{
415 {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }},
416 {"POST", func(u, bearer string) (int, string) {
417 return postRaw(t, u, map[string]any{"env_prefix": "BLOBS"}, bearer)
418 }},
419 }
420 for _, d := range doors {
421 t.Run(d.name, func(t *testing.T) {
422 // (a) a member of nothing, against a real account and a real app.
423 statusA, bodyA := d.call(storeURL(fx, "titogo"), eve)
424 // (b) a member, against an account that does not exist.
425 statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/store", token)
426 // (c) a member of the account, against an app it never claimed.
427 statusC, bodyC := d.call(storeURL(fx, "ghost-app"), token)
428 if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
429 t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC)
430 }
431 if bodyA != bodyB || bodyB != bodyC {
432 t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC)
433 }
434 if bodyA != "404 page not found\n" {
435 t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA)
436 }
437 })
438 }
439 if rec := storedRecord(t, fx, "titogo"); rec != nil {
440 t.Fatalf("a refused request wrote a record: %+v", rec)
441 }
442}
443
444// TestAPIStoreNoTokenIs401: the token gate is first, before any name is
445// resolved — cliUser's one 401 on both doors.
446func TestAPIStoreNoTokenIs401(t *testing.T) {
447 fx, _ := storeFixture(t, "code-store-notoken")
448
449 if status, _ := getJSON(t, storeURL(fx, "titogo"), ""); status != http.StatusUnauthorized {
450 t.Fatalf("status with no token = %d, want 401", status)
451 }
452 if status, _ := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, ""); status != http.StatusUnauthorized {
453 t.Fatalf("declare with no token = %d, want 401", status)
454 }
455}
456
457// TestAPIStoreDeclareIsRateLimited: the shared write budget, keyed on the
458// ADDRESS like every other write door here — a budget belongs to the person,
459// not the terminal they typed on.
460func TestAPIStoreDeclareIsRateLimited(t *testing.T) {
461 fx, token := storeFixture(t, "code-store-limit")
462 fx.srv.cli.write = newRateLimiter(1, time.Minute)
463
464 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
465 t.Fatalf("first declare = %d %v", status, out)
466 }
467 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusTooManyRequests {
468 t.Fatalf("second declare on an exhausted budget = %d %v, want 429", status, out)
469 }
470}
471
472// TestAPIStoreWithoutABucketIs503: the record IS a bucket object. A console
473// with nowhere to put it — or to read it from — must say so rather than
474// answer for a store it cannot see.
475func TestAPIStoreWithoutABucketIs503(t *testing.T) {
476 fx, token := storeFixture(t, "code-store-nobucket")
477 fx.srv.cfg.Releases = nil
478
479 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusServiceUnavailable {
480 t.Fatalf("declare with no bucket = %d %v, want 503", status, out)
481 }
482 if status, out := getJSON(t, storeURL(fx, "titogo"), token); status != http.StatusServiceUnavailable {
483 t.Fatalf("status with no bucket = %d %v, want 503", status, out)
484 }
485}
486
487// ---- grant, rotate, finish (the operator half) -------------------------------
488//
489// What these pin, in the order it would hurt if lost: nobody outside
490// CARLOS_STORE_OPERATORS can make this deployment spend money, and the member
491// gate still runs FIRST so the allowlist never becomes an oracle; a grant that
492// is refused for any reason leaves the backend untouched; a grant that
493// succeeds delivers the minted secret sealed into the app's own config and
494// announces itself to the fleet; and the rotation state machine keeps its
495// two-key discipline — rotate leaves both keys live and bumps, finish kills
496// the old one and bumps nothing.
497
498// storeArmed is storeFixture with a whole deployment behind it: an in-memory
499// backend, a deployment that mints carlos-<acct>-<app> buckets in eu-west-1, a
500// secrets key the console can seal with, and alice on the operator allowlist.
501//
502// It hands back the fake (assert on the estate it actually built), the
503// member's token, and the base64 PRIVATE key — the half this console never
504// holds — so a test can prove the secret delivered into config is the one the
505// backend minted, rather than merely that four variables exist.
506func storeArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, string, string) {
507 t.Helper()
508 fx, token := storeFixture(t, code)
509 pub, priv, _ := sealedFor(t, "irrelevant")
510 fake := objstore.NewMemProvisioner("eu-west-1")
511 fx.srv.cfg.SecretsPub = pub
512 fx.srv.cfg.StoreProvisioner = fake
513 fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
514 fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "test-current-webhook-master", "")
515 // Spelled with different capitals on purpose: an operator list is typed by
516 // a human into a unit file, and addresses are case-insensitive.
517 fx.srv.cfg.StoreOperators = []string{"Alice@Keymail.dev"}
518 return fx, fake, token, priv
519}
520
521// declareStore is `carlos store create` — the member half every operator verb
522// below needs to exist before it has anything to act on.
523func declareStore(t *testing.T, fx *consoleFixture, token string) {
524 t.Helper()
525 if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
526 t.Fatalf("declare = %d %v", status, out)
527 }
528}
529
530// appVars reads the app's config manifest straight out of the bucket — the
531// only place a delivered store credential ever lands.
532func appVars(t *testing.T, fx *consoleFixture, app string) map[string]appconfig.Var {
533 t.Helper()
534 m, err := appconfig.Get(context.Background(), fx.rel, release.OpsAccount, app)
535 if err != nil {
536 t.Fatalf("read config: %v", err)
537 }
538 return m.Vars
539}
540
541// openSecret decrypts a delivered secret with the private key the console has
542// never seen.
543func openSecret(t *testing.T, priv string, v appconfig.Var) string {
544 t.Helper()
545 pk, err := appconfig.ParsePrivateKey(priv, "test")
546 if err != nil {
547 t.Fatal(err)
548 }
549 plain, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, v.Envelope)
550 if err != nil {
551 t.Fatalf("open envelope: %v", err)
552 }
553 return string(plain)
554}
555
556// untouched asserts the backend has nothing in it — the check every refusal
557// owes: a door that refuses AFTER creating a bucket has already spent money.
558func untouched(t *testing.T, fake *objstore.MemProvisioner, when string) {
559 t.Helper()
560 if len(fake.Buckets) != 0 || len(fake.Users) != 0 {
561 t.Fatalf("%s touched the backend: buckets %v, users %v", when, fake.Buckets, fake.Users)
562 }
563}
564
565// TestAPIStoreGrantNeedsADeploymentOperator: a member of the account — the
566// person who declared the store — cannot grant it. Provisioning spends real
567// money in the DEPLOYMENT's cloud account, so the authority belongs to
568// whoever runs the deployment; the refusal names the variable that carries
569// that list, because the only person who can fix it is the one who can read
570// it. An empty list is fail-closed: nobody may grant.
571func TestAPIStoreGrantNeedsADeploymentOperator(t *testing.T) {
572 fx, fake, token, _ := storeArmed(t, "code-store-grant-operator")
573 declareStore(t, fx, token)
574 fx.srv.cfg.StoreOperators = []string{"paul@keymail.dev"} // alice is not on it
575
576 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
577 if status != http.StatusForbidden {
578 t.Fatalf("grant by a non-operator member = %d %v, want 403", status, out)
579 }
580 if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") {
581 t.Fatalf("error = %q, want the operator list named", msg)
582 }
583 untouched(t, fake, "a non-operator grant")
584 if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
585 t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
586 }
587 if got := serial(t, fx); got != 0 {
588 t.Fatalf("serial after a refused grant = %d, want 0", got)
589 }
590
591 // Unset is not "everybody" — the direction a spending gate must fail.
592 fx.srv.cfg.StoreOperators = nil
593 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden {
594 t.Fatalf("grant with no operator list = %d %v, want 403", status, out)
595 }
596
597 // The allowlist runs BEFORE the deployment-shape 503s. A non-operator who
598 // could tell "this deployment has no object storage" from "you may not
599 // grant" would have turned the grant door into a config oracle for every
600 // member of every account.
601 fx.srv.cfg.StoreProvisioner = nil
602 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden {
603 t.Fatalf("grant by a non-operator on a backend-less console = %d %v, want 403 — not the 503", status, out)
604 }
605 fx.srv.cfg.Releases = nil
606 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden {
607 t.Fatalf("grant by a non-operator on a bucket-less console = %d %v, want 403", status, out)
608 }
609}
610
611// TestAPIStoreGrantAllowlistDoesNotFold: the allowlist is an EXACT match on
612// the canonical (ASCII-lowercased) address every entry point in this codebase
613// writes — not a Unicode fold. U+017F (ſ) folds to "s" and is already
614// lowercase, so it sails through every ToLower on the way in and ValidAddress
615// accepts it; under strings.EqualFold, owner@example.teſt would be handed the
616// authority to spend this deployment's money on behalf of owner@example.test.
617func TestAPIStoreGrantAllowlistDoesNotFold(t *testing.T) {
618 fx, fake, token, _ := storeArmed(t, "code-store-grant-fold")
619 declareStore(t, fx, token)
620 fx.srv.cfg.StoreOperators = []string{"owner@example.test"}
621 const homoglyph = "owner@example.teſt" // owner@example.teſt
622 if err := fx.st.AddMember(OpsAccountID, homoglyph, RoleMember, time.Now()); err != nil {
623 t.Fatal(err)
624 }
625 impostor := cliToken(t, fx, homoglyph, "code-store-grant-fold-imp")
626
627 // The member gate passes for this address — so a 403 below is the
628 // allowlist refusing, not the account resolution failing by accident.
629 if status, out := getJSON(t, storeURL(fx, "titogo"), impostor); status != http.StatusOK {
630 t.Fatalf("status as the ſ member = %d %v, want 200 — the member gate must be what passed", status, out)
631 }
632
633 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, impostor)
634 if status != http.StatusForbidden {
635 t.Fatalf("grant as %q against an allowlist of owner@example.test = %d %v, want 403", homoglyph, status, out)
636 }
637 untouched(t, fake, "a fold-matched grant")
638 if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
639 t.Fatalf("a fold-matched grant wrote a grant block: %+v", rec.Grant)
640 }
641 // The address the list actually names still works, however it was typed.
642 fx.srv.cfg.StoreOperators = []string{" Alice@Keymail.dev "}
643 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
644 t.Fatalf("grant by the listed operator = %d %v, want 200", status, out)
645 }
646}
647
648// TestAPIStoreOperatorDoorsGateIsByteIdentical: the member gate runs BEFORE
649// the allowlist on all three doors — eve here IS a deployment operator, and
650// she still gets the same 404 as an unknown account and an app that isn't
651// there. If the allowlist ran first, a 403 would tell an operator which of a
652// deployment's accounts and apps exist; if it ran instead of the member gate,
653// it would let one in.
654func TestAPIStoreOperatorDoorsGateIsByteIdentical(t *testing.T) {
655 fx, _, token, _ := storeArmed(t, "code-store-op-gate")
656 fx.srv.cfg.StoreOperators = []string{"eve@keymail.dev", "alice@keymail.dev"}
657 eve := cliToken(t, fx, "eve@keymail.dev", "code-store-op-gate-eve")
658
659 for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} {
660 t.Run(door, func(t *testing.T) {
661 // (a) an operator who is a member of nothing, against a real app.
662 statusA, bodyA := postRaw(t, storeURL(fx, "titogo")+door, map[string]any{}, eve)
663 // (b) a member, against an account that does not exist.
664 statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/store"+door, map[string]any{}, token)
665 // (c) a member of the account, against an app it never claimed.
666 statusC, bodyC := postRaw(t, storeURL(fx, "ghost-app")+door, map[string]any{}, token)
667 if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
668 t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", door, statusA, statusB, statusC)
669 }
670 if bodyA != bodyB || bodyB != bodyC {
671 t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", door, bodyA, bodyB, bodyC)
672 }
673 if bodyA != "404 page not found\n" {
674 t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", door, bodyA)
675 }
676 })
677 }
678 if status, _ := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, ""); status != http.StatusUnauthorized {
679 t.Fatalf("grant with no token = %d, want 401", status)
680 }
681}
682
683// TestAPIStoreGrantWithoutABackendIs503: a deployment with no object storage
684// wired up refuses the verbs that would create something, and names the
685// variable that would wire it. The member's declaration still stands — it is a
686// request an operator fulfils, and the operator is exactly who this sentence
687// is for.
688func TestAPIStoreGrantWithoutABackendIs503(t *testing.T) {
689 fx, _, token, _ := storeArmed(t, "code-store-grant-nobackend")
690 declareStore(t, fx, token)
691 fx.srv.cfg.StoreProvisioner = nil
692
693 for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} {
694 status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token)
695 if status != http.StatusServiceUnavailable {
696 t.Fatalf("%s with no backend = %d %v, want 503", door, status, out)
697 }
698 if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_BACKEND") {
699 t.Fatalf("%s error = %q, want the backend variable named", door, msg)
700 }
701 }
702 if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
703 t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
704 }
705}
706
707// TestAPIStoreGrantProvisionsAndDelivers: the whole authority path. One call
708// mints a bucket, an IAM principal and a key, seals both credential halves
709// into the app's own config, records what it built, and bumps the serial so
710// the fleet picks the config up in seconds rather than at the hourly floor.
711//
712// The secret is checked by DECRYPTING it with the key the console never holds:
713// "four variables are present" would pass just as happily on a delivery that
714// sealed the wrong bytes.
715func TestAPIStoreGrantProvisionsAndDelivers(t *testing.T) {
716 fx, fake, token, priv := storeArmed(t, "code-store-grant-happy")
717 declareStore(t, fx, token)
718 if got := serial(t, fx); got != 0 {
719 t.Fatalf("serial before the grant = %d, want 0", got)
720 }
721
722 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
723 if status != http.StatusOK {
724 t.Fatalf("grant = %d %v, want 200", status, out)
725 }
726 prov := block(t, out, "provisioned")
727 const bucket = "carlos-bab-titogo"
728 const user = "carlos-store-bab-titogo"
729 if prov["bucket"] != bucket || prov["region"] != "eu-west-1" || prov["iam_user"] != user {
730 t.Fatalf("provisioned = %v, want the minted bucket, its region and the app's principal", prov)
731 }
732 keyID, _ := prov["key_id"].(string)
733 if keyID == "" {
734 t.Fatalf("provisioned.key_id = %v, want the minted key id", prov["key_id"])
735 }
736 if out["pending_finish"] != false {
737 t.Fatalf("pending_finish = %v, want false on a first grant", out["pending_finish"])
738 }
739 if got := strList(t, out, "missing_vars"); len(got) != 0 {
740 t.Fatalf("missing_vars = %v, want nothing missing right after a delivery", got)
741 }
742 if block(t, out, "grant")["by"] != "alice@keymail.dev" {
743 t.Fatalf("grant.by = %v, want the operator who asked", block(t, out, "grant")["by"])
744 }
745
746 // The estate the fake actually built: one bucket, one principal, one key.
747 if _, ok := fake.Buckets[bucket]; !ok {
748 t.Fatalf("buckets = %v, want %s", fake.Buckets, bucket)
749 }
750 u, ok := fake.Users[user]
751 if !ok || len(u.Keys) != 1 {
752 t.Fatalf("users = %v, want %s holding exactly one key", fake.Users, user)
753 }
754 secret, ok := u.Keys[keyID]
755 if !ok {
756 t.Fatalf("keys = %v, want the recorded key id %q among them", u.Keys, keyID)
757 }
758
759 // Delivery: coordinates in the clear, both credential halves sealed.
760 vars := appVars(t, fx, "titogo")
761 if v := vars["CARLOS_STORE_BUCKET"]; v.Kind != "env" || v.Value != bucket {
762 t.Fatalf("CARLOS_STORE_BUCKET = %+v, want the bucket as a plain env var", v)
763 }
764 if v := vars["CARLOS_STORE_REGION"]; v.Kind != "env" || v.Value != "eu-west-1" {
765 t.Fatalf("CARLOS_STORE_REGION = %+v", v)
766 }
767 if _, present := vars["CARLOS_STORE_ENDPOINT"]; present {
768 t.Fatalf("CARLOS_STORE_ENDPOINT was delivered by a deployment that has no endpoint")
769 }
770 access := vars["CARLOS_STORE_ACCESS_KEY"]
771 if access.Kind != "secret" || access.Value != "" {
772 t.Fatalf("CARLOS_STORE_ACCESS_KEY = %+v, want a sealed secret and no plaintext", access)
773 }
774 if got := openSecret(t, priv, access); got != keyID {
775 t.Fatalf("sealed access key = %q, want the minted key id %q", got, keyID)
776 }
777 sealedSecret := vars["CARLOS_STORE_SECRET_KEY"]
778 if sealedSecret.Kind != "secret" || sealedSecret.Value != "" {
779 t.Fatalf("CARLOS_STORE_SECRET_KEY = %+v, want a sealed secret and no plaintext", sealedSecret)
780 }
781 if got := openSecret(t, priv, sealedSecret); got != secret {
782 t.Fatalf("sealed secret half = %q, want the secret the backend minted", got)
783 }
784
785 // Recorded, announced, and audited.
786 rec := storedRecord(t, fx, "titogo")
787 if rec.Provisioned == nil || rec.Provisioned.KeyID != keyID || rec.Provisioned.Bucket != bucket {
788 t.Fatalf("record after a grant = %+v", rec.Provisioned)
789 }
790 if rec.Grant == nil || rec.Grant.Backend != "mem" {
791 t.Fatalf("grant block = %+v, want this deployment's backend", rec.Grant)
792 }
793 if got := serial(t, fx); got != 1 {
794 t.Fatalf("serial after a grant = %d, want 1 — the app's config changed", got)
795 }
796 auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", bucket)
797
798 // Idempotent: the operator's verb is one people re-run, and a healthy
799 // store must not mint a second key for it.
800 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
801 t.Fatalf("re-grant = %d %v, want 200", status, out)
802 }
803 if u := fake.Users[user]; len(u.Keys) != 1 || u.Keys[keyID] != secret {
804 t.Fatalf("keys after a re-grant = %v, want the same single key", u.Keys)
805 }
806}
807
808// TestAPIStoreGrantAdoptsANamedBucket: the operator can point a store at a
809// bucket that already exists — the migration path for data that predates the
810// platform — and the record says so, so nothing later re-derives a minted name
811// over the top of it.
812func TestAPIStoreGrantAdoptsANamedBucket(t *testing.T) {
813 fx, fake, token, _ := storeArmed(t, "code-store-grant-adopt")
814 declareStore(t, fx, token)
815
816 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": "titogo-uploads"}, token)
817 if status != http.StatusOK {
818 t.Fatalf("adopt grant = %d %v, want 200", status, out)
819 }
820 if got := block(t, out, "provisioned")["bucket"]; got != "titogo-uploads" {
821 t.Fatalf("provisioned.bucket = %v, want the adopted name", got)
822 }
823 if _, ok := fake.Buckets["carlos-bab-titogo"]; ok {
824 t.Fatalf("an adopting grant also minted the default bucket: %v", fake.Buckets)
825 }
826 rec := storedRecord(t, fx, "titogo")
827 if rec.Grant == nil || rec.Grant.Bucket != "titogo-uploads" {
828 t.Fatalf("grant block = %+v, want the adopted bucket recorded", rec.Grant)
829 }
830 auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", "titogo-uploads")
831}
832
833// TestAPIStoreGrantRefusesAnotherStoresBucket (#218): adopting a bucket that
834// another app's store record already names would scope this app's credential
835// to that app's data — and tearing this store down later would delete it. The
836// door refuses before anything is created or recorded, including for a record
837// written before name claims existed, which holds no claim to find.
838func TestAPIStoreGrantRefusesAnotherStoresBucket(t *testing.T) {
839 fx, fake, token, _ := storeArmed(t, "code-store-grant-taken")
840 declareStore(t, fx, token)
841 putRecord(t, fx, "other", &objstore.Record{
842 Declared: &objstore.Declared{EnvPrefix: "OTHER_S3", At: time.Now(), By: "paul@keymail.dev"},
843 Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "other-uploads", At: time.Now(), By: "op"},
844 })
845
846 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": "other-uploads"}, token)
847 if status != http.StatusConflict {
848 t.Fatalf("adopting another store's bucket = %d %v, want 409", status, out)
849 }
850 if msg := str(t, out, "error"); !strings.Contains(msg, "another store") {
851 t.Fatalf("error = %q, want it to say the name belongs to another store", msg)
852 }
853 untouched(t, fake, "adopting another store's bucket")
854 if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
855 t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
856 }
857}
858
859// TestAPIStoreGrantRefusesAnInvalidBucketName: an adopt name is interpolated
860// into the IAM policy ARN the credential is scoped by, so a bad one is refused
861// AT THE DOOR — before the record is read and before a single backend call, so
862// nothing exists to clean up and a corrected retry starts from where the
863// operator left off.
864func TestAPIStoreGrantRefusesAnInvalidBucketName(t *testing.T) {
865 fx, fake, token, _ := storeArmed(t, "code-store-grant-badbucket")
866 declareStore(t, fx, token)
867
868 for _, bad := range []string{"Uploads", "up", "up_loads", "-uploads", "uploads-", strings.Repeat("u", 64), "uploads/*"} {
869 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": bad}, token)
870 if status != http.StatusBadRequest {
871 t.Fatalf("grant with bucket %q = %d %v, want 400", bad, status, out)
872 }
873 untouched(t, fake, "an invalid bucket name")
874 if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil {
875 t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant)
876 }
877 }
878}
879
880// TestAPIStoreGrantWithoutADeclarationIs409: the operator's verb never invents
881// the member's half. The env prefix is the member's choice and a store nobody
882// asked for has nowhere to be delivered, so the refusal names the command that
883// fixes it — and nothing is created.
884func TestAPIStoreGrantWithoutADeclarationIs409(t *testing.T) {
885 fx, fake, token, _ := storeArmed(t, "code-store-grant-undeclared")
886
887 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
888 if status != http.StatusConflict {
889 t.Fatalf("grant with no declaration = %d %v, want 409", status, out)
890 }
891 if msg := str(t, out, "error"); !strings.Contains(msg, "store create") {
892 t.Fatalf("error = %q, want the member's command named", msg)
893 }
894 untouched(t, fake, "a grant with no declaration")
895 if got := serial(t, fx); got != 0 {
896 t.Fatalf("serial = %d, want 0 — nothing was delivered", got)
897 }
898}
899
900// TestAPIStoreGrantNeedsASealingKey: a console with no CARLOS_SECRETS_PUBKEY
901// cannot seal, therefore cannot DELIVER, therefore must not MINT — a key
902// created here would exist in the backend with its secret already lost. Same
903// for a rotation. Finish is the exception, and deliberately: it seals nothing,
904// it deletes a key and records that, and a console that lost its pubkey must
905// still be able to close a two-key window.
906func TestAPIStoreGrantNeedsASealingKey(t *testing.T) {
907 fx, fake, token, _ := storeArmed(t, "code-store-grant-nokey")
908 declareStore(t, fx, token)
909 pub := fx.srv.cfg.SecretsPub
910 fx.srv.cfg.SecretsPub = ""
911
912 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
913 if status != http.StatusServiceUnavailable {
914 t.Fatalf("grant with no secrets key = %d %v, want 503", status, out)
915 }
916 untouched(t, fake, "a grant with no sealing key")
917
918 // A key that is present but unreadable is the same refusal: what matters
919 // is whether this console can seal, not whether someone set the variable.
920 fx.srv.cfg.SecretsPub = "not-a-key"
921 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusServiceUnavailable {
922 t.Fatalf("grant with an unreadable secrets key = %d %v, want 503", status, out)
923 }
924 untouched(t, fake, "a grant with an unreadable sealing key")
925
926 // Provision and rotate for real, then take the key away again.
927 fx.srv.cfg.SecretsPub = pub
928 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
929 t.Fatalf("grant = %d %v", status, out)
930 }
931 if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusOK {
932 t.Fatalf("rotate = %d %v", status, out)
933 }
934 fx.srv.cfg.SecretsPub = ""
935 if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusServiceUnavailable {
936 t.Fatalf("rotate with no secrets key = %d %v, want 503", status, out)
937 }
938 status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, token)
939 if status != http.StatusOK {
940 t.Fatalf("finish with no secrets key = %d %v, want 200 — finishing seals nothing", status, out)
941 }
942 if out["pending_finish"] != false {
943 t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"])
944 }
945}
946
947// TestAPIStoreRotateAndFinish: the two-key state machine, end to end, driven
948// by a MEMBER who is not a deployment operator — rotating touches only the
949// estate an operator already paid for, and a member must not have to find one
950// to cycle their own credential.
951//
952// Rotate leaves BOTH keys live and bumps the serial (the app's config
953// changed). Finish kills the old key and bumps NOTHING: no object a box reads
954// moved, and waking the fleet for it would be noise.
955func TestAPIStoreRotateAndFinish(t *testing.T) {
956 fx, fake, token, priv := storeArmed(t, "code-store-rotate")
957 declareStore(t, fx, token)
958 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
959 t.Fatalf("grant = %d %v", status, out)
960 }
961 firstKey := storedRecord(t, fx, "titogo").Provisioned.KeyID
962 if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil {
963 t.Fatal(err)
964 }
965 bob := cliToken(t, fx, "bob@keymail.dev", "code-store-rotate-bob")
966
967 status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, bob)
968 if status != http.StatusOK {
969 t.Fatalf("rotate by a member = %d %v, want 200 — rotation is a member verb", status, out)
970 }
971 prov := block(t, out, "provisioned")
972 newKey, _ := prov["key_id"].(string)
973 if newKey == "" || newKey == firstKey {
974 t.Fatalf("key_id = %v, want a fresh key (was %q)", prov["key_id"], firstKey)
975 }
976 if prov["prev_key_id"] != firstKey {
977 t.Fatalf("prev_key_id = %v, want the key the app is still holding (%q)", prov["prev_key_id"], firstKey)
978 }
979 if out["pending_finish"] != true {
980 t.Fatalf("pending_finish = %v after a rotate, want true", out["pending_finish"])
981 }
982 const user = "carlos-store-bab-titogo"
983 if u := fake.Users[user]; len(u.Keys) != 2 {
984 t.Fatalf("keys after a rotate = %v, want both live until finish", u.Keys)
985 }
986 if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != fake.Users[user].Keys[newKey] {
987 t.Fatalf("delivered secret = %q, want the newly minted one", got)
988 }
989 if got := serial(t, fx); got != 2 {
990 t.Fatalf("serial after grant+rotate = %d, want 2 — the app's config changed again", got)
991 }
992 auditedBy(t, fx, "titogo", "store-rotate", "bob@keymail.dev", newKey)
993
994 status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob)
995 if status != http.StatusOK {
996 t.Fatalf("finish = %d %v, want 200", status, out)
997 }
998 if out["pending_finish"] != false {
999 t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"])
1000 }
1001 if block(t, out, "provisioned")["key_id"] != newKey {
1002 t.Fatalf("finish moved the live key: %v", block(t, out, "provisioned"))
1003 }
1004 u := fake.Users[user]
1005 if len(u.Keys) != 1 {
1006 t.Fatalf("keys after a finish = %v, want only the live one", u.Keys)
1007 }
1008 if _, ok := u.Keys[firstKey]; ok {
1009 t.Fatalf("finish left the previous key %q alive", firstKey)
1010 }
1011 if got := serial(t, fx); got != 2 {
1012 t.Fatalf("serial after a finish = %d, want it unmoved at 2 — nothing a box reads changed", got)
1013 }
1014 auditedBy(t, fx, "titogo", "store-rotate-finish", "bob@keymail.dev", newKey)
1015
1016 // Finishing twice refuses rather than succeeding vacuously: a member who
1017 // believes they just retired a key, when an earlier call already did, has a
1018 // different picture of the estate than the estate does.
1019 status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob)
1020 if status != http.StatusConflict {
1021 t.Fatalf("second finish = %d %v, want 409", status, out)
1022 }
1023 if msg := str(t, out, "error"); !strings.Contains(msg, "pending") {
1024 t.Fatalf("error = %q, want it to say nothing is pending", msg)
1025 }
1026}
1027
1028// TestAPIStoreRotateWithoutAProvisionedStoreIs409: rotation edits an existing
1029// credential estate — it never creates one, because the coordinates it reseals
1030// come from the record and there is no defensible guess for them. Both verbs
1031// point at the one that does create it.
1032func TestAPIStoreRotateWithoutAProvisionedStoreIs409(t *testing.T) {
1033 fx, fake, token, _ := storeArmed(t, "code-store-rotate-unprovisioned")
1034 declareStore(t, fx, token)
1035
1036 for _, door := range []string{"/rotate", "/rotate/finish"} {
1037 status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token)
1038 if status != http.StatusConflict {
1039 t.Fatalf("%s on a declared-only store = %d %v, want 409", door, status, out)
1040 }
1041 if msg := str(t, out, "error"); !strings.Contains(msg, "grant") {
1042 t.Fatalf("%s error = %q, want the grant named as the way forward", door, msg)
1043 }
1044 }
1045 untouched(t, fake, "a rotate with nothing provisioned")
1046 if got := serial(t, fx); got != 0 {
1047 t.Fatalf("serial = %d, want 0", got)
1048 }
1049}
1050
1051// racingStore lands a competing write on one key immediately BEFORE the
1052// handler's own Put reaches the bucket — the interleaving that turns a walk's
1053// etag stale. One shot, so only the write under test races. Mirrors the
1054// wrapper objstore's walk tests use, at the console's own seam: the fixture
1055// owns Config.Releases, so swapping it in is all it takes to make a
1056// two-console race reproducible in one process.
1057type racingStore struct {
1058 release.Store
1059 raceKeyOnce string
1060 race func(inner release.Store)
1061}
1062
1063func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
1064 if key != "" && key == r.raceKeyOnce {
1065 r.raceKeyOnce = ""
1066 r.race(r.Store)
1067 }
1068 return r.Store.Put(ctx, key, body, ifMatch)
1069}
1070
1071// TestAPIStoreRotateConflictSaysReRun: the highest-stakes sentence in this
1072// file. A rotation that loses its final CAS to ANOTHER rotation has already
1073// minted a key and delivered it into the app's config — the walk refuses to
1074// overwrite the competitor's record precisely because that record names a key
1075// somebody else delivered. So the answer must never read as "nothing
1076// happened": a member told their rotation failed would go looking for a key
1077// that is already live, and might reach for a grant, which reaps.
1078func TestAPIStoreRotateConflictSaysReRun(t *testing.T) {
1079 fx, _, token, _ := storeArmed(t, "code-store-rotate-conflict")
1080 declareStore(t, fx, token)
1081 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
1082 t.Fatalf("grant = %d %v", status, out)
1083 }
1084 firstKey := storedRecord(t, fx, "titogo").Provisioned.KeyID
1085
1086 // From here on, the console writes through a store that lets a competing
1087 // rotation land in the window between this walk's Get and its Put.
1088 racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.Key(release.OpsAccount, "titogo")}
1089 racer.race = func(in release.Store) {
1090 other, etag, err := objstore.Get(context.Background(), in, release.OpsAccount, "titogo")
1091 if err != nil {
1092 t.Fatal(err)
1093 }
1094 // Another rotation got there first and delivered a key of its own.
1095 other.Provisioned.KeyID = "MEMKEY99"
1096 other.Provisioned.PrevKeyID = firstKey
1097 if _, err := objstore.Put(context.Background(), in, release.OpsAccount, "titogo", other, etag); err != nil {
1098 t.Fatal(err)
1099 }
1100 }
1101 fx.srv.cfg.Releases = racer
1102
1103 status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token)
1104 if status != http.StatusConflict {
1105 t.Fatalf("rotate over a concurrent rotation = %d %v, want 409", status, out)
1106 }
1107 if msg := str(t, out, "error"); msg != "another rotation is in progress — re-run to converge" {
1108 t.Fatalf("error = %q, want the re-run-to-converge sentence", msg)
1109 }
1110 // The words that would be a lie: this caller HAS minted and delivered.
1111 for _, forbidden := range []string{"nothing", "failed", "try again"} {
1112 if strings.Contains(strings.ToLower(str(t, out, "error")), forbidden) {
1113 t.Fatalf("error = %q, must not suggest the rotation left nothing behind", out["error"])
1114 }
1115 }
1116 // The competitor's record stands — the whole reason the walk gave up.
1117 if rec := storedRecord(t, fx, "titogo"); rec.Provisioned.KeyID != "MEMKEY99" {
1118 t.Fatalf("the competing rotation was clobbered: %+v", rec.Provisioned)
1119 }
1120}
1121
1122// TestAPIStoreGrantFailureSaysResume: a backend that refuses gets ONE fixed
1123// sentence, and the answer carries no bucket name, ARN, endpoint or request id
1124// — those go to the console log, where the operator can read them and a
1125// tenant cannot. The sentence's promise is what the walk's ordering
1126// guarantees: the grant block naming the bucket is written BEFORE anything is
1127// created, so nothing exists that only a bill would find, and the same verb
1128// run again resumes from where it stopped.
1129func TestAPIStoreGrantFailureSaysResume(t *testing.T) {
1130 fx, fake, token, _ := storeArmed(t, "code-store-grant-failure")
1131 declareStore(t, fx, token)
1132 fake.FailNext["EnsureBucket"] = errors.New(
1133 "AccessDenied: arn:aws:s3:::carlos-bab-titogo — status code 403, request id 8EXAMPLE7")
1134
1135 status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token)
1136 if status != http.StatusBadGateway {
1137 t.Fatalf("grant against a refusing backend = %d %v, want 502", status, out)
1138 }
1139 msg := str(t, out, "error")
1140 if msg != "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume" {
1141 t.Fatalf("error = %q, want the fixed resume sentence", msg)
1142 }
1143 for _, leak := range []string{"carlos-bab-titogo", "AccessDenied", "arn:aws", "403", "8EXAMPLE7"} {
1144 if strings.Contains(msg, leak) {
1145 t.Fatalf("error = %q, leaks backend detail %q", msg, leak)
1146 }
1147 }
1148 // Recorded before created: the bucket the walk was going to make is named
1149 // in the record, which is exactly why the sentence above is true.
1150 rec := storedRecord(t, fx, "titogo")
1151 if rec.Grant == nil || rec.Grant.Bucket != "carlos-bab-titogo" {
1152 t.Fatalf("grant block after a failed walk = %+v, want the bucket recorded", rec.Grant)
1153 }
1154 if rec.Provisioned != nil {
1155 t.Fatalf("provisioned = %+v after a failed walk", rec.Provisioned)
1156 }
1157 if got := serial(t, fx); got != 0 {
1158 t.Fatalf("serial after a failed grant = %d, want 0 — nothing was delivered", got)
1159 }
1160 // The one-shot failure is spent; re-running resumes and completes.
1161 if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK {
1162 t.Fatalf("re-run grant = %d %v, want 200", status, out)
1163 }
1164 if _, ok := fake.Buckets["carlos-bab-titogo"]; !ok {
1165 t.Fatalf("buckets after the resume = %v", fake.Buckets)
1166 }
1167}
1168
1169// TestAPIStoreStatusCarriesTheSweptSize: `size` is copied from the newest
1170// storage sweep, dated. It is null when no sweep has measured the store, and
1171// a sweep that did not see the store provisioned is never shown (its 0 would
1172// read as an empty bucket).
1173func TestAPIStoreStatusCarriesTheSweptSize(t *testing.T) {
1174 fx, token := storeFixture(t, "code-store-size")
1175 ctx := context.Background()
1176 now := time.Now()
1177 putRecord(t, fx, "titogo", &objstore.Record{
1178 Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"},
1179 Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"},
1180 Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-titogo", Region: "eu-west-1",
1181 IAMUser: "u", KeyID: "AKIA", At: now},
1182 })
1183 key := release.OpsAccount + "/titogo"
1184 seed := func(day time.Time, st usage.AppStorage) {
1185 t.Helper()
1186 body, _ := json.Marshal(usage.StorageDay{V: 1, Date: usage.DayOf(day),
1187 Apps: map[string]usage.AppStorage{key: st}})
1188 if _, err := fx.rel.Put(ctx, usage.StorageDayKey(usage.DayOf(day)), body, ""); err != nil {
1189 t.Fatal(err)
1190 }
1191 }
1192 size := func() map[string]any {
1193 t.Helper()
1194 code, out := getJSON(t, storeURL(fx, "titogo"), token)
1195 if code != http.StatusOK {
1196 t.Fatalf("status = %d %v", code, out)
1197 }
1198 if out["size"] == nil {
1199 return nil
1200 }
1201 return block(t, out, "size")
1202 }
1203
1204 if got := size(); got != nil {
1205 t.Fatalf("size = %v with no sweep at all, want null", got)
1206 }
1207
1208 // A sweep that ran before the grant: the store was not there to size.
1209 seed(now.AddDate(0, 0, -2), usage.AppStorage{BucketBytes: 10})
1210 if got := size(); got != nil {
1211 t.Fatalf("size = %v from a sweep that did not see the store, want null", got)
1212 }
1213
1214 seed(now.AddDate(0, 0, -1), usage.AppStorage{BucketBytes: 10, StoreBytes: 5 << 20, StoreProvisioned: true})
1215 got := size()
1216 if got == nil || got["store_bytes"] != float64(5<<20) || got["as_of"] != usage.DayOf(now.AddDate(0, 0, -1)) {
1217 t.Fatalf("size = %v, want yesterday's 5MB", got)
1218 }
1219
1220 // Today's sweep could not read the bucket: the newest answer says so.
1221 seed(now, usage.AppStorage{BucketBytes: 10, StoreUnswept: true})
1222 if got := size(); got == nil || got["unswept"] != true || got["as_of"] != usage.DayOf(now) {
1223 t.Fatalf("size = %v, want today's unswept marker", got)
1224 }
1225}
1226