| index 10a36d848..d168e667d 100644 |
| --- a/internal/console/apistore_test.go |
| +++ b/internal/console/apistore_test.go |
| @@ -11,6 +11,7 @@ package console |
| |
| import ( |
| "context" |
| + "errors" |
| "net/http" |
| "strings" |
| "testing" |
| @@ -590,6 +591,56 @@ func TestAPIStoreGrantNeedsADeploymentOperator(t *testing.T) { |
| if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden { |
| t.Fatalf("grant with no operator list = %d %v, want 403", status, out) |
| } |
| + |
| + // The allowlist runs BEFORE the deployment-shape 503s. A non-operator who |
| + // could tell "this deployment has no object storage" from "you may not |
| + // grant" would have turned the grant door into a config oracle for every |
| + // member of every account. |
| + fx.srv.cfg.StoreProvisioner = nil |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden { |
| + t.Fatalf("grant by a non-operator on a backend-less console = %d %v, want 403 — not the 503", status, out) |
| + } |
| + fx.srv.cfg.Releases = nil |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusForbidden { |
| + t.Fatalf("grant by a non-operator on a bucket-less console = %d %v, want 403", status, out) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantAllowlistDoesNotFold: the allowlist is an EXACT match on |
| +// the canonical (ASCII-lowercased) address every entry point in this codebase |
| +// writes — not a Unicode fold. U+017F (ſ) folds to "s" and is already |
| +// lowercase, so it sails through every ToLower on the way in and ValidAddress |
| +// accepts it; under strings.EqualFold, paul@rſlw.com would be handed the |
| +// authority to spend this deployment's money on behalf of paul@rslw.com. |
| +func TestAPIStoreGrantAllowlistDoesNotFold(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-fold") |
| + declareStore(t, fx, token) |
| + fx.srv.cfg.StoreOperators = []string{"paul@rslw.com"} |
| + const homoglyph = "paul@rſlw.com" // paul@rſlw.com |
| + if err := fx.st.AddMember(OpsAccountID, homoglyph, RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + impostor := cliToken(t, fx, homoglyph, "code-store-grant-fold-imp") |
| + |
| + // The member gate passes for this address — so a 403 below is the |
| + // allowlist refusing, not the account resolution failing by accident. |
| + if status, out := getJSON(t, storeURL(fx, "titogo"), impostor); status != http.StatusOK { |
| + t.Fatalf("status as the ſ member = %d %v, want 200 — the member gate must be what passed", status, out) |
| + } |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, impostor) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("grant as %q against an allowlist of paul@rslw.com = %d %v, want 403", homoglyph, status, out) |
| + } |
| + untouched(t, fake, "a fold-matched grant") |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("a fold-matched grant wrote a grant block: %+v", rec.Grant) |
| + } |
| + // The address the list actually names still works, however it was typed. |
| + fx.srv.cfg.StoreOperators = []string{" Alice@Keymail.dev "} |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("grant by the listed operator = %d %v, want 200", status, out) |
| + } |
| } |
| |
| // TestAPIStoreOperatorDoorsGateIsByteIdentical: the member gate runs BEFORE |
| @@ -968,3 +1019,121 @@ func TestAPIStoreRotateWithoutAProvisionedStoreIs409(t *testing.T) { |
| t.Fatalf("serial = %d, want 0", got) |
| } |
| } |
| + |
| +// racingStore lands a competing write on one key immediately BEFORE the |
| +// handler's own Put reaches the bucket — the interleaving that turns a walk's |
| +// etag stale. One shot, so only the write under test races. Mirrors the |
| +// wrapper objstore's walk tests use, at the console's own seam: the fixture |
| +// owns Config.Releases, so swapping it in is all it takes to make a |
| +// two-console race reproducible in one process. |
| +type racingStore struct { |
| + release.Store |
| + raceKeyOnce string |
| + race func(inner release.Store) |
| +} |
| + |
| +func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key != "" && key == r.raceKeyOnce { |
| + r.raceKeyOnce = "" |
| + r.race(r.Store) |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// TestAPIStoreRotateConflictSaysReRun: the highest-stakes sentence in this |
| +// file. A rotation that loses its final CAS to ANOTHER rotation has already |
| +// minted a key and delivered it into the app's config — the walk refuses to |
| +// overwrite the competitor's record precisely because that record names a key |
| +// somebody else delivered. So the answer must never read as "nothing |
| +// happened": a member told their rotation failed would go looking for a key |
| +// that is already live, and might reach for a grant, which reaps. |
| +func TestAPIStoreRotateConflictSaysReRun(t *testing.T) { |
| + fx, _, token, _ := storeArmed(t, "code-store-rotate-conflict") |
| + declareStore(t, fx, token) |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("grant = %d %v", status, out) |
| + } |
| + firstKey := storedRecord(t, fx, "titogo").Provisioned.KeyID |
| + |
| + // From here on, the console writes through a store that lets a competing |
| + // rotation land in the window between this walk's Get and its Put. |
| + racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.Key(release.OpsAccount, "titogo")} |
| + racer.race = func(in release.Store) { |
| + other, etag, err := objstore.Get(context.Background(), in, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Another rotation got there first and delivered a key of its own. |
| + other.Provisioned.KeyID = "MEMKEY99" |
| + other.Provisioned.PrevKeyID = firstKey |
| + if _, err := objstore.Put(context.Background(), in, release.OpsAccount, "titogo", other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + fx.srv.cfg.Releases = racer |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("rotate over a concurrent rotation = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); msg != "another rotation is in progress — re-run to converge" { |
| + t.Fatalf("error = %q, want the re-run-to-converge sentence", msg) |
| + } |
| + // The words that would be a lie: this caller HAS minted and delivered. |
| + for _, forbidden := range []string{"nothing", "failed", "try again"} { |
| + if strings.Contains(strings.ToLower(str(t, out, "error")), forbidden) { |
| + t.Fatalf("error = %q, must not suggest the rotation left nothing behind", out["error"]) |
| + } |
| + } |
| + // The competitor's record stands — the whole reason the walk gave up. |
| + if rec := storedRecord(t, fx, "titogo"); rec.Provisioned.KeyID != "MEMKEY99" { |
| + t.Fatalf("the competing rotation was clobbered: %+v", rec.Provisioned) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantFailureSaysResume: a backend that refuses gets ONE fixed |
| +// sentence, and the answer carries no bucket name, ARN, endpoint or request id |
| +// — those go to the console log, where the operator can read them and a |
| +// tenant cannot. The sentence's promise is what the walk's ordering |
| +// guarantees: the grant block naming the bucket is written BEFORE anything is |
| +// created, so nothing exists that only a bill would find, and the same verb |
| +// run again resumes from where it stopped. |
| +func TestAPIStoreGrantFailureSaysResume(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-failure") |
| + declareStore(t, fx, token) |
| + fake.FailNext["EnsureBucket"] = errors.New( |
| + "AccessDenied: arn:aws:s3:::carlos-bab-titogo — status code 403, request id 8EXAMPLE7") |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("grant against a refusing backend = %d %v, want 502", status, out) |
| + } |
| + msg := str(t, out, "error") |
| + if msg != "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume" { |
| + t.Fatalf("error = %q, want the fixed resume sentence", msg) |
| + } |
| + for _, leak := range []string{"carlos-bab-titogo", "AccessDenied", "arn:aws", "403", "8EXAMPLE7"} { |
| + if strings.Contains(msg, leak) { |
| + t.Fatalf("error = %q, leaks backend detail %q", msg, leak) |
| + } |
| + } |
| + // Recorded before created: the bucket the walk was going to make is named |
| + // in the record, which is exactly why the sentence above is true. |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Grant == nil || rec.Grant.Bucket != "carlos-bab-titogo" { |
| + t.Fatalf("grant block after a failed walk = %+v, want the bucket recorded", rec.Grant) |
| + } |
| + if rec.Provisioned != nil { |
| + t.Fatalf("provisioned = %+v after a failed walk", rec.Provisioned) |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a failed grant = %d, want 0 — nothing was delivered", got) |
| + } |
| + // The one-shot failure is spent; re-running resumes and completes. |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("re-run grant = %d %v, want 200", status, out) |
| + } |
| + if _, ok := fake.Buckets["carlos-bab-titogo"]; !ok { |
| + t.Fatalf("buckets after the resume = %v", fake.Buckets) |
| + } |
| +} |