| index 78df0c231..10a36d848 100644 |
| --- a/internal/console/apistore_test.go |
| +++ b/internal/console/apistore_test.go |
| @@ -481,3 +481,490 @@ func TestAPIStoreWithoutABucketIs503(t *testing.T) { |
| t.Fatalf("status with no bucket = %d %v, want 503", status, out) |
| } |
| } |
| + |
| +// ---- grant, rotate, finish (the operator half) ------------------------------- |
| +// |
| +// What these pin, in the order it would hurt if lost: nobody outside |
| +// CARLOS_STORE_OPERATORS can make this deployment spend money, and the member |
| +// gate still runs FIRST so the allowlist never becomes an oracle; a grant that |
| +// is refused for any reason leaves the backend untouched; a grant that |
| +// succeeds delivers the minted secret sealed into the app's own config and |
| +// announces itself to the fleet; and the rotation state machine keeps its |
| +// two-key discipline — rotate leaves both keys live and bumps, finish kills |
| +// the old one and bumps nothing. |
| + |
| +// storeArmed is storeFixture with a whole deployment behind it: an in-memory |
| +// backend, a deployment that mints carlos-<acct>-<app> buckets in eu-west-1, a |
| +// secrets key the console can seal with, and alice on the operator allowlist. |
| +// |
| +// It hands back the fake (assert on the estate it actually built), the |
| +// member's token, and the base64 PRIVATE key — the half this console never |
| +// holds — so a test can prove the secret delivered into config is the one the |
| +// backend minted, rather than merely that four variables exist. |
| +func storeArmed(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, string, string) { |
| + t.Helper() |
| + fx, token := storeFixture(t, code) |
| + pub, priv, _ := sealedFor(t, "irrelevant") |
| + fake := objstore.NewMemProvisioner("eu-west-1") |
| + fx.srv.cfg.SecretsPub = pub |
| + fx.srv.cfg.StoreProvisioner = fake |
| + fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"} |
| + // Spelled with different capitals on purpose: an operator list is typed by |
| + // a human into a unit file, and addresses are case-insensitive. |
| + fx.srv.cfg.StoreOperators = []string{"Alice@Keymail.dev"} |
| + return fx, fake, token, priv |
| +} |
| + |
| +// declareStore is `carlos store create` — the member half every operator verb |
| +// below needs to exist before it has anything to act on. |
| +func declareStore(t *testing.T, fx *consoleFixture, token string) { |
| + t.Helper() |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| +} |
| + |
| +// appVars reads the app's config manifest straight out of the bucket — the |
| +// only place a delivered store credential ever lands. |
| +func appVars(t *testing.T, fx *consoleFixture, app string) map[string]appconfig.Var { |
| + t.Helper() |
| + m, err := appconfig.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatalf("read config: %v", err) |
| + } |
| + return m.Vars |
| +} |
| + |
| +// openSecret decrypts a delivered secret with the private key the console has |
| +// never seen. |
| +func openSecret(t *testing.T, priv string, v appconfig.Var) string { |
| + t.Helper() |
| + pk, err := appconfig.ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + plain, err := appconfig.Open(pk, v.Envelope) |
| + if err != nil { |
| + t.Fatalf("open envelope: %v", err) |
| + } |
| + return string(plain) |
| +} |
| + |
| +// untouched asserts the backend has nothing in it — the check every refusal |
| +// owes: a door that refuses AFTER creating a bucket has already spent money. |
| +func untouched(t *testing.T, fake *objstore.MemProvisioner, when string) { |
| + t.Helper() |
| + if len(fake.Buckets) != 0 || len(fake.Users) != 0 { |
| + t.Fatalf("%s touched the backend: buckets %v, users %v", when, fake.Buckets, fake.Users) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantNeedsADeploymentOperator: a member of the account — the |
| +// person who declared the store — cannot grant it. Provisioning spends real |
| +// money in the DEPLOYMENT's cloud account, so the authority belongs to |
| +// whoever runs the deployment; the refusal names the variable that carries |
| +// that list, because the only person who can fix it is the one who can read |
| +// it. An empty list is fail-closed: nobody may grant. |
| +func TestAPIStoreGrantNeedsADeploymentOperator(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-operator") |
| + declareStore(t, fx, token) |
| + fx.srv.cfg.StoreOperators = []string{"paul@keymail.dev"} // alice is not on it |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("grant by a non-operator member = %d %v, want 403", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") { |
| + t.Fatalf("error = %q, want the operator list named", msg) |
| + } |
| + untouched(t, fake, "a non-operator grant") |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant) |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a refused grant = %d, want 0", got) |
| + } |
| + |
| + // Unset is not "everybody" — the direction a spending gate must fail. |
| + fx.srv.cfg.StoreOperators = nil |
| + 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) |
| + } |
| +} |
| + |
| +// TestAPIStoreOperatorDoorsGateIsByteIdentical: the member gate runs BEFORE |
| +// the allowlist on all three doors — eve here IS a deployment operator, and |
| +// she still gets the same 404 as an unknown account and an app that isn't |
| +// there. If the allowlist ran first, a 403 would tell an operator which of a |
| +// deployment's accounts and apps exist; if it ran instead of the member gate, |
| +// it would let one in. |
| +func TestAPIStoreOperatorDoorsGateIsByteIdentical(t *testing.T) { |
| + fx, _, token, _ := storeArmed(t, "code-store-op-gate") |
| + fx.srv.cfg.StoreOperators = []string{"eve@keymail.dev", "alice@keymail.dev"} |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-store-op-gate-eve") |
| + |
| + for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} { |
| + t.Run(door, func(t *testing.T) { |
| + // (a) an operator who is a member of nothing, against a real app. |
| + statusA, bodyA := postRaw(t, storeURL(fx, "titogo")+door, map[string]any{}, eve) |
| + // (b) a member, against an account that does not exist. |
| + statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/store"+door, map[string]any{}, token) |
| + // (c) a member of the account, against an app it never claimed. |
| + statusC, bodyC := postRaw(t, storeURL(fx, "ghost-app")+door, map[string]any{}, token) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| + t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", door, statusA, statusB, statusC) |
| + } |
| + if bodyA != bodyB || bodyB != bodyC { |
| + t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", door, bodyA, bodyB, bodyC) |
| + } |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", door, bodyA) |
| + } |
| + }) |
| + } |
| + if status, _ := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, ""); status != http.StatusUnauthorized { |
| + t.Fatalf("grant with no token = %d, want 401", status) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantWithoutABackendIs503: a deployment with no object storage |
| +// wired up refuses the verbs that would create something, and names the |
| +// variable that would wire it. The member's declaration still stands — it is a |
| +// request an operator fulfils, and the operator is exactly who this sentence |
| +// is for. |
| +func TestAPIStoreGrantWithoutABackendIs503(t *testing.T) { |
| + fx, _, token, _ := storeArmed(t, "code-store-grant-nobackend") |
| + declareStore(t, fx, token) |
| + fx.srv.cfg.StoreProvisioner = nil |
| + |
| + for _, door := range []string{"/grant", "/rotate", "/rotate/finish"} { |
| + status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("%s with no backend = %d %v, want 503", door, status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_BACKEND") { |
| + t.Fatalf("%s error = %q, want the backend variable named", door, msg) |
| + } |
| + } |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantProvisionsAndDelivers: the whole authority path. One call |
| +// mints a bucket, an IAM principal and a key, seals both credential halves |
| +// into the app's own config, records what it built, and bumps the serial so |
| +// the fleet picks the config up in seconds rather than at the hourly floor. |
| +// |
| +// The secret is checked by DECRYPTING it with the key the console never holds: |
| +// "four variables are present" would pass just as happily on a delivery that |
| +// sealed the wrong bytes. |
| +func TestAPIStoreGrantProvisionsAndDelivers(t *testing.T) { |
| + fx, fake, token, priv := storeArmed(t, "code-store-grant-happy") |
| + declareStore(t, fx, token) |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial before the grant = %d, want 0", got) |
| + } |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("grant = %d %v, want 200", status, out) |
| + } |
| + prov := block(t, out, "provisioned") |
| + const bucket = "carlos-bab-titogo" |
| + const user = "carlos-store-bab-titogo" |
| + if prov["bucket"] != bucket || prov["region"] != "eu-west-1" || prov["iam_user"] != user { |
| + t.Fatalf("provisioned = %v, want the minted bucket, its region and the app's principal", prov) |
| + } |
| + keyID, _ := prov["key_id"].(string) |
| + if keyID == "" { |
| + t.Fatalf("provisioned.key_id = %v, want the minted key id", prov["key_id"]) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v, want false on a first grant", out["pending_finish"]) |
| + } |
| + if got := strList(t, out, "missing_vars"); len(got) != 0 { |
| + t.Fatalf("missing_vars = %v, want nothing missing right after a delivery", got) |
| + } |
| + if block(t, out, "grant")["by"] != "alice@keymail.dev" { |
| + t.Fatalf("grant.by = %v, want the operator who asked", block(t, out, "grant")["by"]) |
| + } |
| + |
| + // The estate the fake actually built: one bucket, one principal, one key. |
| + if _, ok := fake.Buckets[bucket]; !ok { |
| + t.Fatalf("buckets = %v, want %s", fake.Buckets, bucket) |
| + } |
| + u, ok := fake.Users[user] |
| + if !ok || len(u.Keys) != 1 { |
| + t.Fatalf("users = %v, want %s holding exactly one key", fake.Users, user) |
| + } |
| + secret, ok := u.Keys[keyID] |
| + if !ok { |
| + t.Fatalf("keys = %v, want the recorded key id %q among them", u.Keys, keyID) |
| + } |
| + |
| + // Delivery: coordinates in the clear, both credential halves sealed. |
| + vars := appVars(t, fx, "titogo") |
| + if v := vars["CARLOS_STORE_BUCKET"]; v.Kind != "env" || v.Value != bucket { |
| + t.Fatalf("CARLOS_STORE_BUCKET = %+v, want the bucket as a plain env var", v) |
| + } |
| + if v := vars["CARLOS_STORE_REGION"]; v.Kind != "env" || v.Value != "eu-west-1" { |
| + t.Fatalf("CARLOS_STORE_REGION = %+v", v) |
| + } |
| + if _, present := vars["CARLOS_STORE_ENDPOINT"]; present { |
| + t.Fatalf("CARLOS_STORE_ENDPOINT was delivered by a deployment that has no endpoint") |
| + } |
| + access := vars["CARLOS_STORE_ACCESS_KEY"] |
| + if access.Kind != "secret" || access.Value != "" { |
| + t.Fatalf("CARLOS_STORE_ACCESS_KEY = %+v, want a sealed secret and no plaintext", access) |
| + } |
| + if got := openSecret(t, priv, access); got != keyID { |
| + t.Fatalf("sealed access key = %q, want the minted key id %q", got, keyID) |
| + } |
| + sealedSecret := vars["CARLOS_STORE_SECRET_KEY"] |
| + if sealedSecret.Kind != "secret" || sealedSecret.Value != "" { |
| + t.Fatalf("CARLOS_STORE_SECRET_KEY = %+v, want a sealed secret and no plaintext", sealedSecret) |
| + } |
| + if got := openSecret(t, priv, sealedSecret); got != secret { |
| + t.Fatalf("sealed secret half = %q, want the secret the backend minted", got) |
| + } |
| + |
| + // Recorded, announced, and audited. |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Provisioned == nil || rec.Provisioned.KeyID != keyID || rec.Provisioned.Bucket != bucket { |
| + t.Fatalf("record after a grant = %+v", rec.Provisioned) |
| + } |
| + if rec.Grant == nil || rec.Grant.Backend != "mem" { |
| + t.Fatalf("grant block = %+v, want this deployment's backend", rec.Grant) |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial after a grant = %d, want 1 — the app's config changed", got) |
| + } |
| + auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", bucket) |
| + |
| + // Idempotent: the operator's verb is one people re-run, and a healthy |
| + // store must not mint a second key for it. |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("re-grant = %d %v, want 200", status, out) |
| + } |
| + if u := fake.Users[user]; len(u.Keys) != 1 || u.Keys[keyID] != secret { |
| + t.Fatalf("keys after a re-grant = %v, want the same single key", u.Keys) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantAdoptsANamedBucket: the operator can point a store at a |
| +// bucket that already exists — the migration path for data that predates the |
| +// platform — and the record says so, so nothing later re-derives a minted name |
| +// over the top of it. |
| +func TestAPIStoreGrantAdoptsANamedBucket(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-adopt") |
| + declareStore(t, fx, token) |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": "titogo-uploads"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("adopt grant = %d %v, want 200", status, out) |
| + } |
| + if got := block(t, out, "provisioned")["bucket"]; got != "titogo-uploads" { |
| + t.Fatalf("provisioned.bucket = %v, want the adopted name", got) |
| + } |
| + if _, ok := fake.Buckets["carlos-bab-titogo"]; ok { |
| + t.Fatalf("an adopting grant also minted the default bucket: %v", fake.Buckets) |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Grant == nil || rec.Grant.Bucket != "titogo-uploads" { |
| + t.Fatalf("grant block = %+v, want the adopted bucket recorded", rec.Grant) |
| + } |
| + auditedBy(t, fx, "titogo", "store-grant", "alice@keymail.dev", "titogo-uploads") |
| +} |
| + |
| +// TestAPIStoreGrantRefusesAnInvalidBucketName: an adopt name is interpolated |
| +// into the IAM policy ARN the credential is scoped by, so a bad one is refused |
| +// AT THE DOOR — before the record is read and before a single backend call, so |
| +// nothing exists to clean up and a corrected retry starts from where the |
| +// operator left off. |
| +func TestAPIStoreGrantRefusesAnInvalidBucketName(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-badbucket") |
| + declareStore(t, fx, token) |
| + |
| + for _, bad := range []string{"Uploads", "up", "up_loads", "-uploads", "uploads-", strings.Repeat("u", 64), "uploads/*"} { |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{"bucket": bad}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("grant with bucket %q = %d %v, want 400", bad, status, out) |
| + } |
| + untouched(t, fake, "an invalid bucket name") |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("a refused grant wrote a grant block: %+v", rec.Grant) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantWithoutADeclarationIs409: the operator's verb never invents |
| +// the member's half. The env prefix is the member's choice and a store nobody |
| +// asked for has nowhere to be delivered, so the refusal names the command that |
| +// fixes it — and nothing is created. |
| +func TestAPIStoreGrantWithoutADeclarationIs409(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-undeclared") |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("grant with no declaration = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "store create") { |
| + t.Fatalf("error = %q, want the member's command named", msg) |
| + } |
| + untouched(t, fake, "a grant with no declaration") |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d, want 0 — nothing was delivered", got) |
| + } |
| +} |
| + |
| +// TestAPIStoreGrantNeedsASealingKey: a console with no CARLOS_SECRETS_PUBKEY |
| +// cannot seal, therefore cannot DELIVER, therefore must not MINT — a key |
| +// created here would exist in the backend with its secret already lost. Same |
| +// for a rotation. Finish is the exception, and deliberately: it seals nothing, |
| +// it deletes a key and records that, and a console that lost its pubkey must |
| +// still be able to close a two-key window. |
| +func TestAPIStoreGrantNeedsASealingKey(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-grant-nokey") |
| + declareStore(t, fx, token) |
| + pub := fx.srv.cfg.SecretsPub |
| + fx.srv.cfg.SecretsPub = "" |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("grant with no secrets key = %d %v, want 503", status, out) |
| + } |
| + untouched(t, fake, "a grant with no sealing key") |
| + |
| + // A key that is present but unreadable is the same refusal: what matters |
| + // is whether this console can seal, not whether someone set the variable. |
| + fx.srv.cfg.SecretsPub = "not-a-key" |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusServiceUnavailable { |
| + t.Fatalf("grant with an unreadable secrets key = %d %v, want 503", status, out) |
| + } |
| + untouched(t, fake, "a grant with an unreadable sealing key") |
| + |
| + // Provision and rotate for real, then take the key away again. |
| + fx.srv.cfg.SecretsPub = pub |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("grant = %d %v", status, out) |
| + } |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("rotate = %d %v", status, out) |
| + } |
| + fx.srv.cfg.SecretsPub = "" |
| + if status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, token); status != http.StatusServiceUnavailable { |
| + t.Fatalf("rotate with no secrets key = %d %v, want 503", status, out) |
| + } |
| + status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("finish with no secrets key = %d %v, want 200 — finishing seals nothing", status, out) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"]) |
| + } |
| +} |
| + |
| +// TestAPIStoreRotateAndFinish: the two-key state machine, end to end, driven |
| +// by a MEMBER who is not a deployment operator — rotating touches only the |
| +// estate an operator already paid for, and a member must not have to find one |
| +// to cycle their own credential. |
| +// |
| +// Rotate leaves BOTH keys live and bumps the serial (the app's config |
| +// changed). Finish kills the old key and bumps NOTHING: no object a box reads |
| +// moved, and waking the fleet for it would be noise. |
| +func TestAPIStoreRotateAndFinish(t *testing.T) { |
| + fx, fake, token, priv := storeArmed(t, "code-store-rotate") |
| + 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 |
| + if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + bob := cliToken(t, fx, "bob@keymail.dev", "code-store-rotate-bob") |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo")+"/rotate", map[string]any{}, bob) |
| + if status != http.StatusOK { |
| + t.Fatalf("rotate by a member = %d %v, want 200 — rotation is a member verb", status, out) |
| + } |
| + prov := block(t, out, "provisioned") |
| + newKey, _ := prov["key_id"].(string) |
| + if newKey == "" || newKey == firstKey { |
| + t.Fatalf("key_id = %v, want a fresh key (was %q)", prov["key_id"], firstKey) |
| + } |
| + if prov["prev_key_id"] != firstKey { |
| + t.Fatalf("prev_key_id = %v, want the key the app is still holding (%q)", prov["prev_key_id"], firstKey) |
| + } |
| + if out["pending_finish"] != true { |
| + t.Fatalf("pending_finish = %v after a rotate, want true", out["pending_finish"]) |
| + } |
| + const user = "carlos-store-bab-titogo" |
| + if u := fake.Users[user]; len(u.Keys) != 2 { |
| + t.Fatalf("keys after a rotate = %v, want both live until finish", u.Keys) |
| + } |
| + if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != fake.Users[user].Keys[newKey] { |
| + t.Fatalf("delivered secret = %q, want the newly minted one", got) |
| + } |
| + if got := serial(t, fx); got != 2 { |
| + t.Fatalf("serial after grant+rotate = %d, want 2 — the app's config changed again", got) |
| + } |
| + auditedBy(t, fx, "titogo", "store-rotate", "bob@keymail.dev", newKey) |
| + |
| + status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob) |
| + if status != http.StatusOK { |
| + t.Fatalf("finish = %d %v, want 200", status, out) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v after a finish, want false", out["pending_finish"]) |
| + } |
| + if block(t, out, "provisioned")["key_id"] != newKey { |
| + t.Fatalf("finish moved the live key: %v", block(t, out, "provisioned")) |
| + } |
| + u := fake.Users[user] |
| + if len(u.Keys) != 1 { |
| + t.Fatalf("keys after a finish = %v, want only the live one", u.Keys) |
| + } |
| + if _, ok := u.Keys[firstKey]; ok { |
| + t.Fatalf("finish left the previous key %q alive", firstKey) |
| + } |
| + if got := serial(t, fx); got != 2 { |
| + t.Fatalf("serial after a finish = %d, want it unmoved at 2 — nothing a box reads changed", got) |
| + } |
| + auditedBy(t, fx, "titogo", "store-rotate-finish", "bob@keymail.dev", newKey) |
| + |
| + // Finishing twice refuses rather than succeeding vacuously: a member who |
| + // believes they just retired a key, when an earlier call already did, has a |
| + // different picture of the estate than the estate does. |
| + status, out = postJSON(t, storeURL(fx, "titogo")+"/rotate/finish", map[string]any{}, bob) |
| + if status != http.StatusConflict { |
| + t.Fatalf("second finish = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "pending") { |
| + t.Fatalf("error = %q, want it to say nothing is pending", msg) |
| + } |
| +} |
| + |
| +// TestAPIStoreRotateWithoutAProvisionedStoreIs409: rotation edits an existing |
| +// credential estate — it never creates one, because the coordinates it reseals |
| +// come from the record and there is no defensible guess for them. Both verbs |
| +// point at the one that does create it. |
| +func TestAPIStoreRotateWithoutAProvisionedStoreIs409(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-rotate-unprovisioned") |
| + declareStore(t, fx, token) |
| + |
| + for _, door := range []string{"/rotate", "/rotate/finish"} { |
| + status, out := postJSON(t, storeURL(fx, "titogo")+door, map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("%s on a declared-only store = %d %v, want 409", door, status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "grant") { |
| + t.Fatalf("%s error = %q, want the grant named as the way forward", door, msg) |
| + } |
| + } |
| + untouched(t, fake, "a rotate with nothing provisioned") |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial = %d, want 0", got) |
| + } |
| +} |