| new file mode 100644 |
| index 000000000..3d329253a |
| --- /dev/null |
| +++ b/internal/objstore/walk_test.go |
| @@ -0,0 +1,377 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// testSealKeypair mints the secrets keypair the walk seals with and the test |
| +// opens with — the console holds the public half, a host the private one. |
| +func testSealKeypair(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) { |
| + t.Helper() |
| + privB64, pubB64, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := appconfig.ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := appconfig.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return priv, pub |
| +} |
| + |
| +// failingStore fails the FIRST Put to failKeyOnce, then behaves normally — |
| +// the crash between mint and delivery the walk has to repair. |
| +type failingStore struct { |
| + release.Store |
| + failKeyOnce string |
| +} |
| + |
| +func (f *failingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key != "" && key == f.failKeyOnce { |
| + f.failKeyOnce = "" |
| + return "", errors.New("injected: put failed") |
| + } |
| + return f.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func testDeployment() Deployment { |
| + return Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-store-tito-"} |
| +} |
| + |
| +// declare writes the member half of the record: a store is asked for. |
| +func declare(t *testing.T, s release.Store, acct, app, prefix string) { |
| + t.Helper() |
| + rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}} |
| + if _, err := Put(context.Background(), s, acct, app, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func mustVars(t *testing.T, s release.Store, acct, app string) map[string]appconfig.Var { |
| + t.Helper() |
| + m, err := appconfig.Get(context.Background(), s, acct, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m.Vars |
| +} |
| + |
| +func TestGrantUndeclaredRefused(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); !errors.Is(err, ErrNotDeclared) { |
| + t.Fatalf("Grant on undeclared app: %v, want ErrNotDeclared", err) |
| + } |
| + if len(p.Buckets) != 0 || len(p.Users) != 0 { |
| + t.Fatalf("refused grant touched the backend: %v %v", p.Buckets, p.Users) |
| + } |
| +} |
| + |
| +func TestGrantMintsAndDelivers(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + now := time.Now().UTC() |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op@example.com", "", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + const wantBucket = "carlos-store-tito-bab-titogo" |
| + const wantUser = "carlos-store-bab-titogo" |
| + if _, ok := p.Buckets[wantBucket]; !ok { |
| + t.Fatalf("bucket %q not created: %v", wantBucket, p.Buckets) |
| + } |
| + u, ok := p.Users[wantUser] |
| + if !ok { |
| + t.Fatalf("user %q not created: %v", wantUser, p.Users) |
| + } |
| + if rec.Provisioned == nil { |
| + t.Fatal("record has no provisioned block") |
| + } |
| + if rec.Provisioned.Bucket != wantBucket || rec.Provisioned.IAMUser != wantUser || |
| + rec.Provisioned.Region != "eu-west-1" || rec.Provisioned.UserARN != u.ARN { |
| + t.Fatalf("provisioned = %+v", rec.Provisioned) |
| + } |
| + if rec.Grant == nil || rec.Grant.Backend != "aws" || rec.Grant.By != "op@example.com" { |
| + t.Fatalf("grant block = %+v", rec.Grant) |
| + } |
| + secret, ok := u.Keys[rec.Provisioned.KeyID] |
| + if !ok { |
| + t.Fatalf("recorded key %q is not in the fake: %v", rec.Provisioned.KeyID, u.Keys) |
| + } |
| + |
| + // the record is durable, not just the returned value |
| + stored, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored == nil || stored.Provisioned == nil || |
| + stored.Provisioned.KeyID != rec.Provisioned.KeyID { |
| + t.Fatalf("stored record = %+v, %v", stored, err) |
| + } |
| + |
| + vars := mustVars(t, s, "bab", "titogo") |
| + if v := vars["TITO_S3_BUCKET"]; v.Kind != "env" || v.Value != wantBucket { |
| + t.Fatalf("TITO_S3_BUCKET = %+v", v) |
| + } |
| + if v := vars["TITO_S3_REGION"]; v.Kind != "env" || v.Value != "eu-west-1" { |
| + t.Fatalf("TITO_S3_REGION = %+v", v) |
| + } |
| + if v := vars["TITO_S3_ACCESS_KEY"]; v.Kind != "secret" || v.Envelope == "" { |
| + t.Fatalf("TITO_S3_ACCESS_KEY = %+v", v) |
| + } |
| + gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope) |
| + if err != nil || string(gotID) != rec.Provisioned.KeyID { |
| + t.Fatalf("delivered access key = %q, %v; want %q", gotID, err, rec.Provisioned.KeyID) |
| + } |
| + sk := vars["TITO_S3_SECRET_KEY"] |
| + if sk.Kind != "secret" || sk.Value != "" { |
| + t.Fatalf("TITO_S3_SECRET_KEY = %+v (secret must never be plaintext)", sk) |
| + } |
| + gotSecret, err := appconfig.Open(priv, sk.Envelope) |
| + if err != nil || string(gotSecret) != secret { |
| + t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, secret) |
| + } |
| + if _, ok := vars["TITO_S3_ENDPOINT"]; ok { |
| + t.Fatalf("endpoint delivered for an empty endpoint: %+v", vars) |
| + } |
| +} |
| + |
| +func TestGrantDeliversEndpointWhenSet(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-central-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + d := testDeployment() |
| + d.Endpoint = "https://s3.example.net" |
| + rec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Provisioned.Endpoint != d.Endpoint { |
| + t.Fatalf("provisioned endpoint = %q", rec.Provisioned.Endpoint) |
| + } |
| + vars := mustVars(t, s, "bab", "titogo") |
| + if v := vars["TITO_S3_ENDPOINT"]; v.Kind != "env" || v.Value != d.Endpoint { |
| + t.Fatalf("TITO_S3_ENDPOINT = %+v", v) |
| + } |
| +} |
| + |
| +func TestGrantAdoptsExistingBucket(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "titogo-uploads", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Provisioned.Bucket != "titogo-uploads" || rec.Grant.Bucket != "titogo-uploads" { |
| + t.Fatalf("adopt: grant=%+v provisioned=%+v", rec.Grant, rec.Provisioned) |
| + } |
| + if _, ok := p.Buckets["carlos-store-tito-bab-titogo"]; ok { |
| + t.Fatalf("minted bucket created despite adoption: %v", p.Buckets) |
| + } |
| + if v := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"]; v.Value != "titogo-uploads" { |
| + t.Fatalf("delivered bucket = %+v", v) |
| + } |
| +} |
| + |
| +func TestGrantResumesAfterEachStepFailure(t *testing.T) { |
| + for _, method := range []string{"EnsureBucket", "EnsureUser", "MintKey"} { |
| + t.Run(method, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + p.FailNext[method] = errors.New("injected: " + method + " failed") |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err == nil { |
| + t.Fatalf("%s failure: Grant returned no error", method) |
| + } |
| + // The grant block is written before any backend call, so a |
| + // half-failed walk leaves the bucket choice pinned, not provisioned. |
| + half, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || half == nil || half.Grant == nil || half.Provisioned != nil { |
| + t.Fatalf("record after %s failure = %+v, %v", method, half, err) |
| + } |
| + |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatalf("resume after %s failure: %v", method, err) |
| + } |
| + if len(p.Buckets) != 1 { |
| + t.Fatalf("buckets after resume = %v", p.Buckets) |
| + } |
| + if len(p.Users) != 1 { |
| + t.Fatalf("users after resume = %v", p.Users) |
| + } |
| + u := p.Users["carlos-store-bab-titogo"] |
| + if len(u.Keys) != 1 { |
| + t.Fatalf("keys after resume = %v", u.Keys) |
| + } |
| + if _, ok := u.Keys[rec.Provisioned.KeyID]; !ok { |
| + t.Fatalf("recorded key %q not live: %v", rec.Provisioned.KeyID, u.Keys) |
| + } |
| + env := mustVars(t, s, "bab", "titogo")["TITO_S3_SECRET_KEY"].Envelope |
| + got, err := appconfig.Open(priv, env) |
| + if err != nil || string(got) != u.Keys[rec.Provisioned.KeyID] { |
| + t.Fatalf("delivered secret = %q, %v; want %q", got, err, u.Keys[rec.Provisioned.KeyID]) |
| + } |
| + }) |
| + } |
| + |
| + t.Run("delivery", func(t *testing.T) { |
| + ctx := context.Background() |
| + priv, pub := testSealKeypair(t) |
| + s := &failingStore{Store: release.NewDirStore(t.TempDir()), failKeyOnce: appconfig.Key("bab", "titogo")} |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err == nil { |
| + t.Fatal("delivery failure: Grant returned no error") |
| + } |
| + // The record must NOT claim provisioned: the secret never landed. |
| + if rec, _, err := Get(ctx, s, "bab", "titogo"); err != nil || rec == nil || rec.Provisioned != nil { |
| + t.Fatalf("record after failed delivery = %+v, %v", rec, err) |
| + } |
| + |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatalf("resume after delivery failure: %v", err) |
| + } |
| + u := p.Users["carlos-store-bab-titogo"] |
| + if len(u.Keys) != 1 { |
| + t.Fatalf("undelivered key not reaped: %v", u.Keys) |
| + } |
| + if rec.Provisioned.KeyID != "MEMKEY2" { |
| + t.Fatalf("recorded key = %q, want the SECOND mint MEMKEY2", rec.Provisioned.KeyID) |
| + } |
| + vars := mustVars(t, s, "bab", "titogo") |
| + gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope) |
| + if err != nil || string(gotID) != "MEMKEY2" { |
| + t.Fatalf("delivered access key = %q, %v; want MEMKEY2", gotID, err) |
| + } |
| + gotSecret, err := appconfig.Open(priv, vars["TITO_S3_SECRET_KEY"].Envelope) |
| + if err != nil || string(gotSecret) != u.Keys["MEMKEY2"] { |
| + t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, u.Keys["MEMKEY2"]) |
| + } |
| + }) |
| +} |
| + |
| +func TestGrantRerunKeepsBucketChoice(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "titogo-uploads", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Wipe delivery so the re-run walks the whole sequence again. |
| + if _, err := appconfig.Apply(ctx, s, "bab", "titogo", nil, []string{"TITO_S3_BUCKET"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Provisioned.Bucket != "titogo-uploads" || rec.Grant.Bucket != "titogo-uploads" { |
| + t.Fatalf("re-run re-chose the bucket: grant=%+v provisioned=%+v", rec.Grant, rec.Provisioned) |
| + } |
| + if len(p.Buckets) != 1 { |
| + t.Fatalf("buckets = %v", p.Buckets) |
| + } |
| + if v := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"]; v.Value != "titogo-uploads" { |
| + t.Fatalf("re-delivered bucket = %+v", v) |
| + } |
| +} |
| + |
| +func TestGrantIdempotentWhenHealthy(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + first, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before := mustVars(t, s, "bab", "titogo") |
| + |
| + second, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC().Add(time.Hour)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if second.Provisioned.KeyID != first.Provisioned.KeyID { |
| + t.Fatalf("re-run minted a fresh key: %q then %q", first.Provisioned.KeyID, second.Provisioned.KeyID) |
| + } |
| + if u := p.Users["carlos-store-bab-titogo"]; len(u.Keys) != 1 { |
| + t.Fatalf("keys after healthy re-run = %v", u.Keys) |
| + } |
| + after := mustVars(t, s, "bab", "titogo") |
| + if len(before) != len(after) { |
| + t.Fatalf("var set changed: %v then %v", before, after) |
| + } |
| + for k, v := range before { |
| + if after[k] != v { |
| + t.Fatalf("var %s rewritten: %+v then %+v", k, v, after[k]) |
| + } |
| + } |
| +} |
| + |
| +func TestEnvDeliveredReportsMissing(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + |
| + missing, err := EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(missing) != 4 { |
| + t.Fatalf("undelivered app: missing = %v, want the 4 non-endpoint names", missing) |
| + } |
| + |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", "", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false); err != nil || len(missing) != 0 { |
| + t.Fatalf("after grant: missing = %v, %v", missing, err) |
| + } |
| + // endpoint wanted but never delivered (deployment has no endpoint) |
| + if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", true); err != nil || |
| + len(missing) != 1 || missing[0] != "TITO_S3_ENDPOINT" { |
| + t.Fatalf("endpoint wanted: missing = %v, %v", missing, err) |
| + } |
| + if _, err := appconfig.Apply(ctx, s, "bab", "titogo", nil, []string{"TITO_S3_SECRET_KEY"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if missing, err = EnvDelivered(ctx, s, "bab", "titogo", "TITO_S3", false); err != nil || |
| + len(missing) != 1 || missing[0] != "TITO_S3_SECRET_KEY" { |
| + t.Fatalf("after unset: missing = %v, %v", missing, err) |
| + } |
| +} |