package appconfig import ( "context" "crypto/ecdh" "encoding/json" "errors" "strings" "testing" "github.com/carlosframework/platform/internal/release" ) // Issue #213: a box takes an instance's lease only if it can provide that // instance's config. These tests pin the predicate the activator and the // reconcile pass both ask. type canProvideEnv struct { t *testing.T s release.Store priv *ecdh.PrivateKey pub *ecdh.PublicKey other *ecdh.PrivateKey // a key this deployment never sealed for } func newCanProvideEnv(t *testing.T) *canProvideEnv { t.Helper() priv, err := ecdh.P256().GenerateKey(randReader) if err != nil { t.Fatal(err) } other, err := ecdh.P256().GenerateKey(randReader) if err != nil { t.Fatal(err) } return &canProvideEnv{t: t, s: release.NewDirStore(t.TempDir()), priv: priv, pub: priv.PublicKey(), other: other} } func (e *canProvideEnv) put(key string, m Manifest) { e.t.Helper() m.Schema = 1 body, err := json.Marshal(m) if err != nil { e.t.Fatal(err) } if _, err := e.s.Put(context.Background(), key, body, ""); err != nil { e.t.Fatal(err) } } func (e *canProvideEnv) secret(plain string) Var { e.t.Helper() env, err := SealUnbound(e.pub, []byte(plain)) if err != nil { e.t.Fatal(err) } return Var{Kind: "secret", Envelope: env} } func reasonOf(t *testing.T, err error) string { t.Helper() var r *ConfigRefusal if !errors.As(err, &r) { t.Fatalf("err = %v, want a *ConfigRefusal", err) } return r.Reason } func TestCanProvideConfigNothingSealedNeedsNothing(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() // No manifest anywhere: a dev deployment. No key either. if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { t.Fatalf("no manifest, no key: %v", err) } // A manifest with plain vars only has no sealed config. e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { t.Fatalf("plain-only manifest, no key: %v", err) } // A manifest that was fully unset exists but is empty. e.put(Key("bab", "other"), Manifest{App: "other", Vars: map[string]Var{}}) if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "other", "", "a.example.com"); err != nil { t.Fatalf("empty manifest, no key: %v", err) } } func TestCanProvideConfigSealedNeedsAKeyThatOpensIt(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": e.secret("pw")}}) if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { t.Fatalf("right key: %v", err) } if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com")); got != ReasonNoKey { t.Errorf("absent key reason = %q, want %q", got, ReasonNoKey) } if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyUnparseable, "bab", "shop", "", "a.example.com")); got != ReasonKeyUnparseable { t.Errorf("unparseable key reason = %q, want %q", got, ReasonKeyUnparseable) } if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com")); got != ReasonCannotOpen { t.Errorf("wrong key reason = %q, want %q", got, ReasonCannotOpen) } } // The instance overlay and the bound environment are part of what the box // renders, so a secret only there still needs the key. func TestCanProvideConfigLooksAtEveryLayerTheBoxRenders(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) e.put(EnvironmentKey("bab", "shop", "staging"), Manifest{App: "shop", Vars: map[string]Var{"S": e.secret("x")}}) e.put(InstanceConfigKey("bab", "shop", "cust.example.com"), Manifest{App: "shop", Vars: map[string]Var{"T": e.secret("y")}}) // Unbound route, no overlay for this host: nothing sealed. if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "plain.example.com"); err != nil { t.Errorf("unbound plain host: %v", err) } // Bound to the environment: sealed. if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "staging", "plain.example.com")); got != ReasonNoKey { t.Errorf("environment secret reason = %q", got) } // The overlay for cust.example.com only. if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "cust.example.com")); got != ReasonNoKey { t.Errorf("overlay secret reason = %q", got) } if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "cust.example.com"); err != nil { t.Errorf("overlay with the key: %v", err) } } // SyncExec writes the WHOLE config file or none of it (RenderEnv fails if any // one decrypt fails), and instanceEnv accepts a missing file. So a manifest // that is only partly openable on a box with no rendered file would start the // instance with none of its sealed config: fresh admission needs every // envelope to open. func TestCanProvideConfigRefusesPartlyOpenableConfigOnFreshAdmission(t *testing.T) { ctx := context.Background() cases := map[string]func(e *canProvideEnv, foreign string) map[string]Var{ "one foreign-key envelope": func(e *canProvideEnv, f string) map[string]Var { return map[string]Var{"A": {Kind: "secret", Envelope: f}, "C": e.secret("ok")} }, "one corrupt envelope": func(e *canProvideEnv, f string) map[string]Var { return map[string]Var{"B": {Kind: "secret", Envelope: "not-base64!!"}, "C": e.secret("ok")} }, "an unwritable plain value": func(e *canProvideEnv, f string) map[string]Var { return map[string]Var{"P": {Kind: "env", Value: "has \"quote"}, "C": e.secret("ok")} }, "all foreign": func(e *canProvideEnv, f string) map[string]Var { return map[string]Var{"A": {Kind: "secret", Envelope: f}} }, } for name, mk := range cases { t.Run(name, func(t *testing.T) { e := newCanProvideEnv(t) foreign, err := SealUnbound(e.other.PublicKey(), []byte("z")) if err != nil { t.Fatal(err) } vars := mk(e, foreign) e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: vars}) if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com")); got != ReasonCannotOpen { t.Errorf("reason = %q, want cannot_open", got) } }) } } // A v2 envelope is bound to its (account, app): the guard opens it under the // route's own binding, as the sync pass does. func TestCanProvideConfigOpensBoundEnvelopesUnderTheRoutesBinding(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() env, err := SealBound(e.pub, Binding{Account: "bab", App: "shop"}, []byte("pw")) if err != nil { t.Fatal(err) } e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { t.Fatalf("bound envelope, own app: %v", err) } // The same bytes copied under another app do not open there. e.put(Key("bab", "thief"), Manifest{App: "thief", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "thief", "", "a.example.com")); got != ReasonCannotOpen { t.Errorf("copied bound envelope reason = %q", got) } } // failingStore fails reads of the keys it is told to, and delegates the rest. type failingStore struct { release.Store failGet map[string]error } func (f *failingStore) Get(ctx context.Context, key string) ([]byte, string, error) { if err, ok := f.failGet[key]; ok { return nil, "", err } return f.Store.Get(ctx, key) } // "We could not tell" is never "there is no sealed config": a read error on // any layer refuses, with or without a key. func TestCanProvideConfigFailsClosedWhenItCannotRead(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() boom := errors.New("s3: 503 slow down") e.put(EnvironmentKey("bab", "shop", "staging"), Manifest{App: "shop", Vars: map[string]Var{}}) for name, key := range map[string]string{ "default": Key("bab", "shop"), "environment": EnvironmentKey("bab", "shop", "staging"), "overlay": InstanceConfigKey("bab", "shop", "a.example.com"), } { t.Run(name, func(t *testing.T) { fs := &failingStore{Store: e.s, failGet: map[string]error{key: boom}} for _, k := range []struct { priv *ecdh.PrivateKey st KeyState }{{e.priv, KeyOK}, {nil, KeyAbsent}} { err := CanProvideConfig(ctx, fs, k.priv, k.st, "bab", "shop", "staging", "a.example.com") if got := reasonOf(t, err); got != ReasonStoreError { t.Errorf("reason = %q, want %q", got, ReasonStoreError) } if !errors.Is(err, boom) { t.Errorf("err %v does not wrap the store error", err) } } }) } } // A manifest the box cannot parse and a bound environment that is gone are // both "cannot tell". func TestCanProvideConfigFailsClosedOnUnreadableConfig(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() if _, err := e.s.Put(ctx, Key("bab", "garbled"), []byte("{not json"), ""); err != nil { t.Fatal(err) } if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "garbled", "", "a.example.com")); got != ReasonStoreError { t.Errorf("garbled manifest reason = %q", got) } e.put(Key("bab", "bound"), Manifest{App: "bound", Vars: map[string]Var{}}) if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bound", "gone", "a.example.com")); got != ReasonStoreError { t.Errorf("missing bound environment reason = %q", got) } } // A host that fails ValidInstanceHost cannot have an overlay (the sweeps read // that as "no overlay"); the guard must answer, not error on the host shape. func TestCanProvideConfigHostWithNoPossibleOverlay(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "Mixed.Case.example.com"); err != nil { t.Fatalf("host without a possible overlay: %v", err) } } // Nothing secret leaves in the error: not the envelope, not the plaintext. func TestConfigRefusalReasonIsAClosedWord(t *testing.T) { e := newCanProvideEnv(t) ctx := context.Background() v := e.secret("hunter2") e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": v}}) err := CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com") var r *ConfigRefusal if !errors.As(err, &r) { t.Fatal(err) } switch r.Reason { case ReasonNoKey, ReasonKeyUnparseable, ReasonCannotOpen, ReasonStoreError, ReasonNoSource: default: t.Errorf("reason %q is not one of the closed words", r.Reason) } if strings.Contains(err.Error(), v.Envelope) || strings.Contains(err.Error(), "hunter2") { t.Errorf("error leaks sealed material: %v", err) } }